sso
This commit is contained in:
13
Dockerfile
Normal file
13
Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM python:3.9-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY backend ./backend
|
||||||
|
COPY frontend ./frontend
|
||||||
|
|
||||||
|
WORKDIR /app/backend
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||||
56
backend/auth_utils.py
Normal file
56
backend/auth_utils.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from passlib.context import CryptContext
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from . import models, schemas, database
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
SECRET_KEY = "ASF_SSO_SUPER_SECRET_KEY_CHANGE_THIS_IN_PROD"
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 1 day
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
||||||
|
|
||||||
|
def verify_password(plain_password, hashed_password):
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
def get_password_hash(password):
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||||
|
to_encode = data.copy()
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=15)
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(database.get_db)):
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
username: str = payload.get("sub")
|
||||||
|
if username is None:
|
||||||
|
raise credentials_exception
|
||||||
|
token_data = schemas.TokenData(username=username)
|
||||||
|
except JWTError:
|
||||||
|
raise credentials_exception
|
||||||
|
user = db.query(models.User).filter(models.User.username == token_data.username).first()
|
||||||
|
if user is None:
|
||||||
|
raise credentials_exception
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def get_current_admin_user(current_user: models.User = Depends(get_current_user)):
|
||||||
|
if not current_user.is_admin:
|
||||||
|
raise HTTPException(status_code=400, detail="Inactive user or not admin")
|
||||||
|
return current_user
|
||||||
19
backend/database.py
Normal file
19
backend/database.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
SQLALCHEMY_DATABASE_URL = "sqlite:///./sso.db"
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||||
|
)
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
53
backend/main.py
Normal file
53
backend/main.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from fastapi import FastAPI, Depends
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from .database import engine, Base, SessionLocal
|
||||||
|
from .routers import auth, users, apps, sso
|
||||||
|
from . import models, auth_utils
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
# Create tables
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
app = FastAPI(title="ASF SSO Service")
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Routers
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(users.router)
|
||||||
|
app.include_router(apps.router)
|
||||||
|
app.include_router(sso.router)
|
||||||
|
|
||||||
|
# Mount static files (Frontend)
|
||||||
|
app.mount("/", StaticFiles(directory="../frontend", html=True), name="static")
|
||||||
|
|
||||||
|
# Create initial admin user if not exists
|
||||||
|
def create_initial_admin():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
admin = db.query(models.User).filter(models.User.username == "admin").first()
|
||||||
|
if not admin:
|
||||||
|
print("Creating initial admin user...")
|
||||||
|
hashed_pwd = auth_utils.get_password_hash("admin") # Default password, should be changed
|
||||||
|
admin_user = models.User(
|
||||||
|
username="admin",
|
||||||
|
email="admin@nabd-co.com",
|
||||||
|
hashed_password=hashed_pwd,
|
||||||
|
is_admin=True,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.add(admin_user)
|
||||||
|
db.commit()
|
||||||
|
print("Admin user created.")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
create_initial_admin()
|
||||||
40
backend/models.py
Normal file
40
backend/models.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, DateTime
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from datetime import datetime
|
||||||
|
from .database import Base
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
username = Column(String, unique=True, index=True)
|
||||||
|
email = Column(String, unique=True, index=True)
|
||||||
|
hashed_password = Column(String)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
is_admin = Column(Boolean, default=False)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
applications = relationship("UserApplication", back_populates="user")
|
||||||
|
|
||||||
|
class Application(Base):
|
||||||
|
__tablename__ = "applications"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String, unique=True, index=True)
|
||||||
|
api_key = Column(String, unique=True, index=True) # Secret key for the app to talk to SSO
|
||||||
|
url = Column(String)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
users = relationship("UserApplication", back_populates="application")
|
||||||
|
|
||||||
|
class UserApplication(Base):
|
||||||
|
__tablename__ = "user_applications"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"))
|
||||||
|
application_id = Column(Integer, ForeignKey("applications.id"))
|
||||||
|
assigned_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="applications")
|
||||||
|
application = relationship("Application", back_populates="users")
|
||||||
33
backend/routers/apps.py
Normal file
33
backend/routers/apps.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
import secrets
|
||||||
|
from .. import database, models, schemas, auth_utils
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/apps",
|
||||||
|
tags=["Applications"],
|
||||||
|
dependencies=[Depends(auth_utils.get_current_admin_user)]
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/", response_model=schemas.ApplicationOut)
|
||||||
|
async def create_application(app: schemas.ApplicationCreate, db: Session = Depends(database.get_db)):
|
||||||
|
db_app = db.query(models.Application).filter(models.Application.name == app.name).first()
|
||||||
|
if db_app:
|
||||||
|
raise HTTPException(status_code=400, detail="Application already exists")
|
||||||
|
|
||||||
|
api_key = secrets.token_urlsafe(32)
|
||||||
|
db_app = models.Application(
|
||||||
|
name=app.name,
|
||||||
|
url=app.url,
|
||||||
|
api_key=api_key
|
||||||
|
)
|
||||||
|
db.add(db_app)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_app)
|
||||||
|
return db_app
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[schemas.ApplicationOut])
|
||||||
|
async def read_applications(skip: int = 0, limit: int = 100, db: Session = Depends(database.get_db)):
|
||||||
|
apps = db.query(models.Application).offset(skip).limit(limit).all()
|
||||||
|
return apps
|
||||||
22
backend/routers/auth.py
Normal file
22
backend/routers/auth.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from .. import database, models, auth_utils, schemas
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Authentication"])
|
||||||
|
|
||||||
|
@router.post("/token", response_model=schemas.Token)
|
||||||
|
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(database.get_db)):
|
||||||
|
user = db.query(models.User).filter(models.User.username == form_data.username).first()
|
||||||
|
if not user or not auth_utils.verify_password(form_data.password, user.hashed_password):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect username or password",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
access_token_expires = timedelta(minutes=auth_utils.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
access_token = auth_utils.create_access_token(
|
||||||
|
data={"sub": user.username}, expires_delta=access_token_expires
|
||||||
|
)
|
||||||
|
return {"access_token": access_token, "token_type": "bearer"}
|
||||||
31
backend/routers/sso.py
Normal file
31
backend/routers/sso.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from .. import database, models, schemas, auth_utils
|
||||||
|
|
||||||
|
router = APIRouter(tags=["SSO"])
|
||||||
|
|
||||||
|
@router.post("/verify", response_model=schemas.SSOVerifyResponse)
|
||||||
|
async def verify_user(request: schemas.SSOVerifyRequest, db: Session = Depends(database.get_db)):
|
||||||
|
# 1. Validate API Key
|
||||||
|
app = db.query(models.Application).filter(models.Application.api_key == request.api_key).first()
|
||||||
|
if not app:
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid API Key")
|
||||||
|
|
||||||
|
# 2. Validate User Credentials
|
||||||
|
user = db.query(models.User).filter(models.User.username == request.username).first()
|
||||||
|
if not user or not auth_utils.verify_password(request.password, user.hashed_password):
|
||||||
|
return {"authorized": False, "message": "Invalid username or password"}
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
return {"authorized": False, "message": "User account is inactive"}
|
||||||
|
|
||||||
|
# 3. Check Assignment
|
||||||
|
assignment = db.query(models.UserApplication).filter(
|
||||||
|
models.UserApplication.user_id == user.id,
|
||||||
|
models.UserApplication.application_id == app.id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not assignment:
|
||||||
|
return {"authorized": False, "message": "User not authorized for this application"}
|
||||||
|
|
||||||
|
return {"authorized": True, "message": "Authorized", "user": user}
|
||||||
78
backend/routers/users.py
Normal file
78
backend/routers/users.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
from .. import database, models, schemas, auth_utils
|
||||||
|
from ..services import email
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/users",
|
||||||
|
tags=["Users"],
|
||||||
|
dependencies=[Depends(auth_utils.get_current_admin_user)]
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/", response_model=schemas.UserOut)
|
||||||
|
async def create_user(user: schemas.UserCreate, background_tasks: BackgroundTasks, db: Session = Depends(database.get_db)):
|
||||||
|
db_user = db.query(models.User).filter(models.User.username == user.username).first()
|
||||||
|
if db_user:
|
||||||
|
raise HTTPException(status_code=400, detail="Username already registered")
|
||||||
|
|
||||||
|
hashed_password = auth_utils.get_password_hash(user.password)
|
||||||
|
db_user = models.User(
|
||||||
|
username=user.username,
|
||||||
|
email=user.email,
|
||||||
|
hashed_password=hashed_password,
|
||||||
|
is_active=user.is_active,
|
||||||
|
is_admin=user.is_admin
|
||||||
|
)
|
||||||
|
db.add(db_user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_user)
|
||||||
|
|
||||||
|
# Send email
|
||||||
|
background_tasks.add_task(email.send_welcome_email, user.email, user.username, user.password)
|
||||||
|
|
||||||
|
return db_user
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[schemas.UserOut])
|
||||||
|
async def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(database.get_db)):
|
||||||
|
users = db.query(models.User).offset(skip).limit(limit).all()
|
||||||
|
return users
|
||||||
|
|
||||||
|
@router.put("/{user_id}", response_model=schemas.UserOut)
|
||||||
|
async def update_user(user_id: int, user_update: schemas.UserUpdate, background_tasks: BackgroundTasks, db: Session = Depends(database.get_db)):
|
||||||
|
db_user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||||
|
if not db_user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
if user_update.password:
|
||||||
|
db_user.hashed_password = auth_utils.get_password_hash(user_update.password)
|
||||||
|
# Send email on password change
|
||||||
|
background_tasks.add_task(email.send_welcome_email, db_user.email, db_user.username, user_update.password)
|
||||||
|
|
||||||
|
if user_update.email:
|
||||||
|
db_user.email = user_update.email
|
||||||
|
if user_update.username:
|
||||||
|
db_user.username = user_update.username
|
||||||
|
if user_update.is_active is not None:
|
||||||
|
db_user.is_active = user_update.is_active
|
||||||
|
if user_update.is_admin is not None:
|
||||||
|
db_user.is_admin = user_update.is_admin
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_user)
|
||||||
|
return db_user
|
||||||
|
|
||||||
|
@router.post("/{user_id}/assign/{app_id}")
|
||||||
|
async def assign_app_to_user(user_id: int, app_id: int, db: Session = Depends(database.get_db)):
|
||||||
|
assignment = db.query(models.UserApplication).filter(
|
||||||
|
models.UserApplication.user_id == user_id,
|
||||||
|
models.UserApplication.application_id == app_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if assignment:
|
||||||
|
return {"message": "Already assigned"}
|
||||||
|
|
||||||
|
new_assignment = models.UserApplication(user_id=user_id, application_id=app_id)
|
||||||
|
db.add(new_assignment)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Assigned successfully"}
|
||||||
63
backend/schemas.py
Normal file
63
backend/schemas.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# User Schemas
|
||||||
|
class UserBase(BaseModel):
|
||||||
|
username: str
|
||||||
|
email: EmailStr
|
||||||
|
is_active: Optional[bool] = True
|
||||||
|
is_admin: Optional[bool] = False
|
||||||
|
|
||||||
|
class UserCreate(UserBase):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
username: Optional[str] = None
|
||||||
|
email: Optional[EmailStr] = None
|
||||||
|
password: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
is_admin: Optional[bool] = None
|
||||||
|
|
||||||
|
class UserOut(UserBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
|
|
||||||
|
# Application Schemas
|
||||||
|
class ApplicationBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
|
||||||
|
class ApplicationCreate(ApplicationBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ApplicationOut(ApplicationBase):
|
||||||
|
id: int
|
||||||
|
api_key: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
orm_mode = True
|
||||||
|
|
||||||
|
# Token Schema
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str
|
||||||
|
|
||||||
|
class TokenData(BaseModel):
|
||||||
|
username: Optional[str] = None
|
||||||
|
|
||||||
|
# SSO Verification Schema
|
||||||
|
class SSOVerifyRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
api_key: str
|
||||||
|
|
||||||
|
class SSOVerifyResponse(BaseModel):
|
||||||
|
authorized: bool
|
||||||
|
message: str
|
||||||
|
user: Optional[UserOut] = None
|
||||||
45
backend/services/email.py
Normal file
45
backend/services/email.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import aiosmtplib
|
||||||
|
from email.message import EmailMessage
|
||||||
|
import os
|
||||||
|
|
||||||
|
SMTP_HOST = os.getenv("SMTP_ADDRESS", "smtp.gmail.com")
|
||||||
|
SMTP_PORT = int(os.getenv("SMTP_PORT", 587))
|
||||||
|
SMTP_USER = os.getenv("SMTP_USER_NAME", "support@nabd-co.com")
|
||||||
|
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "zwziglbpxyfogafc")
|
||||||
|
|
||||||
|
async def send_email(to_email: str, subject: str, body: str):
|
||||||
|
message = EmailMessage()
|
||||||
|
message["From"] = SMTP_USER
|
||||||
|
message["To"] = to_email
|
||||||
|
message["Subject"] = subject
|
||||||
|
message.set_content(body)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await aiosmtplib.send(
|
||||||
|
message,
|
||||||
|
hostname=SMTP_HOST,
|
||||||
|
port=SMTP_PORT,
|
||||||
|
username=SMTP_USER,
|
||||||
|
password=SMTP_PASSWORD,
|
||||||
|
start_tls=True,
|
||||||
|
)
|
||||||
|
print(f"Email sent to {to_email}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to send email to {to_email}: {e}")
|
||||||
|
|
||||||
|
async def send_welcome_email(to_email: str, username: str, password: str):
|
||||||
|
subject = "Welcome to ASF SSO"
|
||||||
|
body = f"""
|
||||||
|
Hello {username},
|
||||||
|
|
||||||
|
Your account has been created/updated on the ASF SSO platform.
|
||||||
|
|
||||||
|
Username: {username}
|
||||||
|
Password: {password}
|
||||||
|
|
||||||
|
Please login to change your password if needed.
|
||||||
|
|
||||||
|
Regards,
|
||||||
|
ASF Team
|
||||||
|
"""
|
||||||
|
await send_email(to_email, subject, body)
|
||||||
7
caddy_config.txt
Normal file
7
caddy_config.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# -------------------------
|
||||||
|
# SSO Service Proxy
|
||||||
|
# -------------------------
|
||||||
|
sso.nabd-co.com {
|
||||||
|
# Targets the container 'sso_service' on its internal port 8001
|
||||||
|
reverse_proxy sso_service:8001
|
||||||
|
}
|
||||||
15
deploy.sh
Normal file
15
deploy.sh
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Build and start the container
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
echo "SSO Service Deployed!"
|
||||||
|
echo "---------------------------------------------------"
|
||||||
|
echo "Please add the following to your Caddyfile:"
|
||||||
|
echo ""
|
||||||
|
echo "sso.nabd-co.com {"
|
||||||
|
echo " reverse_proxy sso_service:8001"
|
||||||
|
echo "}"
|
||||||
|
echo ""
|
||||||
|
echo "Then reload Caddy."
|
||||||
|
echo "---------------------------------------------------"
|
||||||
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
sso:
|
||||||
|
build: .
|
||||||
|
container_name: sso_service
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
volumes:
|
||||||
|
- sso_data:/app/backend
|
||||||
|
environment:
|
||||||
|
- SMTP_ADDRESS=smtp.gmail.com
|
||||||
|
- SMTP_PORT=587
|
||||||
|
- SMTP_USER_NAME=support@nabd-co.com
|
||||||
|
- SMTP_PASSWORD=zwziglbpxyfogafc
|
||||||
|
networks:
|
||||||
|
- sso_network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
sso_network:
|
||||||
|
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sso_data:
|
||||||
140
docs/api_reference.md
Normal file
140
docs/api_reference.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# ASF SSO API Reference
|
||||||
|
|
||||||
|
This document details the API endpoints available in the ASF SSO service.
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
`https://sso.nabd-co.com` (or `http://localhost:8001` for local dev)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. SSO Verification (External Apps)
|
||||||
|
This is the primary endpoint used by external applications to authenticate users.
|
||||||
|
|
||||||
|
### `POST /verify`
|
||||||
|
|
||||||
|
**Description**: Verifies a user's credentials and checks if they are authorized for the calling application.
|
||||||
|
|
||||||
|
**Headers**:
|
||||||
|
- `Content-Type: application/json`
|
||||||
|
|
||||||
|
**Request Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "jdoe",
|
||||||
|
"password": "secretpassword",
|
||||||
|
"api_key": "YOUR_APP_API_KEY"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (Success - 200 OK)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"authorized": true,
|
||||||
|
"message": "Authorized",
|
||||||
|
"user": {
|
||||||
|
"username": "jdoe",
|
||||||
|
"email": "jdoe@example.com",
|
||||||
|
"is_active": true,
|
||||||
|
"is_admin": false,
|
||||||
|
"id": 5,
|
||||||
|
"created_at": "2026-01-25T12:00:00",
|
||||||
|
"updated_at": "2026-01-25T12:00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (Failure - 200 OK)**:
|
||||||
|
*Note: The API returns 200 OK even for auth failures, but with `authorized: false`.*
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"authorized": false,
|
||||||
|
"message": "Invalid username or password"
|
||||||
|
// OR "User not authorized for this application"
|
||||||
|
// OR "User account is inactive"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Usage (cURL)**:
|
||||||
|
```bash
|
||||||
|
curl -X POST https://sso.nabd-co.com/verify \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "password123",
|
||||||
|
"api_key": "abc123xyz"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Admin Authentication
|
||||||
|
These endpoints are for the Admin Dashboard.
|
||||||
|
|
||||||
|
### `POST /token`
|
||||||
|
|
||||||
|
**Description**: Login as an administrator to get an access token.
|
||||||
|
|
||||||
|
**Request Body (Form Data)**:
|
||||||
|
- `username`: admin
|
||||||
|
- `password`: admin_password
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": "eyJhbGciOiJIUzI1Ni...",
|
||||||
|
"token_type": "bearer"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. User Management (Admin Only)
|
||||||
|
**Requires Header**: `Authorization: Bearer <access_token>`
|
||||||
|
|
||||||
|
### `GET /users/`
|
||||||
|
**Description**: List all users.
|
||||||
|
|
||||||
|
### `POST /users/`
|
||||||
|
**Description**: Create a new user.
|
||||||
|
**Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "newuser",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
"is_admin": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `PUT /users/{user_id}`
|
||||||
|
**Description**: Update a user.
|
||||||
|
**Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "newemail@example.com",
|
||||||
|
"is_active": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /users/{user_id}/assign/{app_id}`
|
||||||
|
**Description**: Assign a user to an application.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Application Management (Admin Only)
|
||||||
|
**Requires Header**: `Authorization: Bearer <access_token>`
|
||||||
|
|
||||||
|
### `GET /apps/`
|
||||||
|
**Description**: List all registered applications.
|
||||||
|
|
||||||
|
### `POST /apps/`
|
||||||
|
**Description**: Register a new application.
|
||||||
|
**Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "OpenProject",
|
||||||
|
"url": "https://openproject.nabd-co.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**Response**:
|
||||||
|
Returns the created app object, including the **`api_key`**.
|
||||||
61
docs/user_guide.md
Normal file
61
docs/user_guide.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# ASF SSO Application - User Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The **ASF SSO (Single Sign-On)** application is a centralized authentication service designed to manage user access across multiple web applications within the ASF ecosystem. It provides a secure and unified way to handle user credentials and application permissions.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
- **Centralized User Management**: Create, update, and manage users from a single admin portal.
|
||||||
|
- **Application Management**: Register new applications and generate secure API keys.
|
||||||
|
- **Access Control**: Assign specific users to specific applications.
|
||||||
|
- **SSO Verification**: Secure API for external applications to verify user credentials and access rights.
|
||||||
|
- **Email Notifications**: Automatically sends welcome emails and password update notifications to users.
|
||||||
|
- **Modern UI**: A responsive, dark-themed dashboard for administrators.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
- **Backend**: Python FastAPI (High performance, easy to maintain).
|
||||||
|
- **Database**: SQLite (Self-contained, easy to backup).
|
||||||
|
- **Frontend**: Vanilla HTML/CSS/JavaScript (Lightweight, no build step required).
|
||||||
|
- **Deployment**: Docker & Docker Compose (Containerized for consistency).
|
||||||
|
|
||||||
|
## Workflows
|
||||||
|
|
||||||
|
### 1. Admin Login
|
||||||
|
The application is protected by an admin login.
|
||||||
|
- **URL**: `https://sso.nabd-co.com`
|
||||||
|
- **Default Credentials**: `admin` / `admin` (Change this immediately after first login).
|
||||||
|
|
||||||
|
### 2. Managing Users
|
||||||
|
- **Create User**:
|
||||||
|
1. Navigate to the **Users** tab.
|
||||||
|
2. Click **Add User**.
|
||||||
|
3. Enter Username, Email, and Password.
|
||||||
|
4. Click **Save**.
|
||||||
|
5. *Result*: The user is created, and a welcome email is sent to them.
|
||||||
|
- **Edit User**: Click **Edit** next to a user to update their details or reset their password.
|
||||||
|
|
||||||
|
### 3. Managing Applications
|
||||||
|
- **Register Application**:
|
||||||
|
1. Navigate to the **Applications** tab.
|
||||||
|
2. Click **Add Application**.
|
||||||
|
3. Enter the Application Name and URL.
|
||||||
|
4. Click **Save**.
|
||||||
|
5. *Result*: The application is listed, and a unique **API Key** is generated.
|
||||||
|
6. **Important**: Copy the API Key. You will need to configure it in the external application.
|
||||||
|
|
||||||
|
### 4. Assigning Access
|
||||||
|
Users cannot log in to an application unless they are explicitly assigned to it.
|
||||||
|
1. Go to the **Users** tab.
|
||||||
|
2. Click **Assign App** next to the user.
|
||||||
|
3. Select the target application from the dropdown.
|
||||||
|
4. Click **Assign**.
|
||||||
|
|
||||||
|
## Integration Logic
|
||||||
|
When a user tries to log in to an external application (e.g., OpenProject):
|
||||||
|
1. The external app collects the username and password from the user.
|
||||||
|
2. The external app sends a secure request to the SSO `verify` endpoint.
|
||||||
|
3. The SSO service checks:
|
||||||
|
- Is the API Key valid?
|
||||||
|
- Are the username and password correct?
|
||||||
|
- Is the user assigned to this application?
|
||||||
|
- Is the user account active?
|
||||||
|
4. If all checks pass, SSO returns `Authorized`. Otherwise, it returns `Unauthorized`.
|
||||||
150
frontend/index.html
Normal file
150
frontend/index.html
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ASF SSO - Admin Portal</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Login Page -->
|
||||||
|
<div id="login-page" class="flex items-center justify-center" style="height: 100vh;">
|
||||||
|
<div class="card text-center" style="width: 100%; max-width: 400px;">
|
||||||
|
<img src="/static/img/logo.png" alt="ASF Logo" class="logo" style="height: 80px; margin-bottom: 2rem;">
|
||||||
|
<h2 class="text-2xl mb-4">Admin Login</h2>
|
||||||
|
<form id="login-form">
|
||||||
|
<input type="text" id="username" placeholder="Username" required>
|
||||||
|
<input type="password" id="password" placeholder="Password" required>
|
||||||
|
<button type="submit" class="btn btn-primary w-full">Login</button>
|
||||||
|
</form>
|
||||||
|
<p id="login-error" class="text-accent mt-4 hidden"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard Layout -->
|
||||||
|
<div id="dashboard-layout" class="hidden">
|
||||||
|
<header class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<img src="/static/img/logo.png" alt="ASF Logo" class="logo">
|
||||||
|
<h1 class="text-xl">ASF SSO Admin</h1>
|
||||||
|
</div>
|
||||||
|
<button id="logout-btn" class="btn btn-secondary">Logout</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="p-8">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="flex gap-4 mb-8">
|
||||||
|
<button class="btn btn-primary" onclick="showSection('users')">Users</button>
|
||||||
|
<button class="btn btn-secondary" onclick="showSection('apps')">Applications</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Users Section -->
|
||||||
|
<section id="users-section">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-2xl">Users</h2>
|
||||||
|
<button class="btn btn-primary" onclick="openModal('user-modal')">Add User</button>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<table id="users-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Admin</th>
|
||||||
|
<th>Active</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- Users will be populated here -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Applications Section -->
|
||||||
|
<section id="apps-section" class="hidden">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-2xl">Applications</h2>
|
||||||
|
<button class="btn btn-primary" onclick="openModal('app-modal')">Add Application</button>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<table id="apps-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>URL</th>
|
||||||
|
<th>API Key</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- Apps will be populated here -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Modal -->
|
||||||
|
<div id="user-modal" class="modal-overlay hidden">
|
||||||
|
<div class="card modal">
|
||||||
|
<h3 class="text-xl mb-4">Add/Edit User</h3>
|
||||||
|
<form id="user-form">
|
||||||
|
<input type="hidden" id="user-id">
|
||||||
|
<input type="text" id="user-username" placeholder="Username" required>
|
||||||
|
<input type="email" id="user-email" placeholder="Email" required>
|
||||||
|
<input type="password" id="user-password" placeholder="Password (leave blank to keep current)">
|
||||||
|
<div class="flex items-center gap-4 mb-4">
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" id="user-is-admin" style="width: auto; margin: 0;"> Admin
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" id="user-is-active" style="width: auto; margin: 0;" checked> Active
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-4">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="closeModal('user-modal')">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- App Modal -->
|
||||||
|
<div id="app-modal" class="modal-overlay hidden">
|
||||||
|
<div class="card modal">
|
||||||
|
<h3 class="text-xl mb-4">Add Application</h3>
|
||||||
|
<form id="app-form">
|
||||||
|
<input type="text" id="app-name" placeholder="Application Name" required>
|
||||||
|
<input type="url" id="app-url" placeholder="Application URL" required>
|
||||||
|
<div class="flex justify-end gap-4">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="closeModal('app-modal')">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assign App Modal -->
|
||||||
|
<div id="assign-modal" class="modal-overlay hidden">
|
||||||
|
<div class="card modal">
|
||||||
|
<h3 class="text-xl mb-4">Assign Application</h3>
|
||||||
|
<form id="assign-form">
|
||||||
|
<input type="hidden" id="assign-user-id">
|
||||||
|
<select id="assign-app-select" required>
|
||||||
|
<option value="">Select Application</option>
|
||||||
|
</select>
|
||||||
|
<div class="flex justify-end gap-4">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="closeModal('assign-modal')">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Assign</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
152
frontend/static/css/style.css
Normal file
152
frontend/static/css/style.css
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
:root {
|
||||||
|
--primary-color: #007bff;
|
||||||
|
--secondary-color: #6c757d;
|
||||||
|
--background-color: #0f172a; /* Dark Blue */
|
||||||
|
--surface-color: #1e293b; /* Lighter Dark Blue */
|
||||||
|
--text-color: #f8fafc;
|
||||||
|
--text-muted: #94a3b8;
|
||||||
|
--accent-color: #38bdf8;
|
||||||
|
--glass-bg: rgba(30, 41, 59, 0.7);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utilities */
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
.flex { display: flex; }
|
||||||
|
.flex-col { flex-direction: column; }
|
||||||
|
.items-center { align-items: center; }
|
||||||
|
.justify-center { justify-content: center; }
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
.gap-4 { gap: 1rem; }
|
||||||
|
.p-4 { padding: 1rem; }
|
||||||
|
.p-8 { padding: 2rem; }
|
||||||
|
.m-4 { margin: 1rem; }
|
||||||
|
.mt-4 { margin-top: 1rem; }
|
||||||
|
.w-full { width: 100%; }
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.text-xl { font-size: 1.25rem; font-weight: bold; }
|
||||||
|
.text-2xl { font-size: 1.5rem; font-weight: bold; }
|
||||||
|
.text-accent { color: var(--accent-color); }
|
||||||
|
|
||||||
|
/* Glassmorphism Card */
|
||||||
|
.card {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--surface-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inputs */
|
||||||
|
input, select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
color: white;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
header {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 40px;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
BIN
frontend/static/img/logo.png
Normal file
BIN
frontend/static/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
257
frontend/static/js/app.js
Normal file
257
frontend/static/js/app.js
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
const API_URL = ""; // Relative path since served by same origin
|
||||||
|
|
||||||
|
// State
|
||||||
|
let token = localStorage.getItem("token");
|
||||||
|
let currentUser = null;
|
||||||
|
|
||||||
|
// DOM Elements
|
||||||
|
const loginPage = document.getElementById("login-page");
|
||||||
|
const dashboardLayout = document.getElementById("dashboard-layout");
|
||||||
|
const loginForm = document.getElementById("login-form");
|
||||||
|
const loginError = document.getElementById("login-error");
|
||||||
|
const logoutBtn = document.getElementById("logout-btn");
|
||||||
|
const usersSection = document.getElementById("users-section");
|
||||||
|
const appsSection = document.getElementById("apps-section");
|
||||||
|
const usersTableBody = document.querySelector("#users-table tbody");
|
||||||
|
const appsTableBody = document.querySelector("#apps-table tbody");
|
||||||
|
|
||||||
|
// Init
|
||||||
|
function init() {
|
||||||
|
if (token) {
|
||||||
|
showDashboard();
|
||||||
|
} else {
|
||||||
|
showLogin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
function showLogin() {
|
||||||
|
loginPage.classList.remove("hidden");
|
||||||
|
dashboardLayout.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDashboard() {
|
||||||
|
loginPage.classList.add("hidden");
|
||||||
|
dashboardLayout.classList.remove("hidden");
|
||||||
|
loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSection(section) {
|
||||||
|
if (section === 'users') {
|
||||||
|
usersSection.classList.remove("hidden");
|
||||||
|
appsSection.classList.add("hidden");
|
||||||
|
loadUsers();
|
||||||
|
} else {
|
||||||
|
usersSection.classList.add("hidden");
|
||||||
|
appsSection.classList.remove("hidden");
|
||||||
|
loadApps();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
loginForm.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const username = document.getElementById("username").value;
|
||||||
|
const password = document.getElementById("password").value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("username", username);
|
||||||
|
formData.append("password", password);
|
||||||
|
|
||||||
|
const res = await fetch(`${API_URL}/token`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Invalid credentials");
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
token = data.access_token;
|
||||||
|
localStorage.setItem("token", token);
|
||||||
|
loginError.classList.add("hidden");
|
||||||
|
showDashboard();
|
||||||
|
} catch (err) {
|
||||||
|
loginError.textContent = err.message;
|
||||||
|
loginError.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logoutBtn.addEventListener("click", () => {
|
||||||
|
token = null;
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
showLogin();
|
||||||
|
});
|
||||||
|
|
||||||
|
// API Helpers
|
||||||
|
async function authFetch(url, options = {}) {
|
||||||
|
const headers = {
|
||||||
|
...options.headers,
|
||||||
|
"Authorization": `Bearer ${token}`
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(url, { ...options, headers });
|
||||||
|
if (res.status === 401) {
|
||||||
|
logoutBtn.click();
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users
|
||||||
|
async function loadUsers() {
|
||||||
|
const res = await authFetch(`${API_URL}/users/`);
|
||||||
|
const users = await res.json();
|
||||||
|
usersTableBody.innerHTML = users.map(user => `
|
||||||
|
<tr>
|
||||||
|
<td>${user.id}</td>
|
||||||
|
<td>${user.username}</td>
|
||||||
|
<td>${user.email}</td>
|
||||||
|
<td>${user.is_admin ? "Yes" : "No"}</td>
|
||||||
|
<td>${user.is_active ? "Yes" : "No"}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-secondary" onclick='editUser(${JSON.stringify(user)})'>Edit</button>
|
||||||
|
<button class="btn btn-primary" onclick='assignAppModal(${user.id})'>Assign App</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apps
|
||||||
|
async function loadApps() {
|
||||||
|
const res = await authFetch(`${API_URL}/apps/`);
|
||||||
|
const apps = await res.json();
|
||||||
|
appsTableBody.innerHTML = apps.map(app => `
|
||||||
|
<tr>
|
||||||
|
<td>${app.id}</td>
|
||||||
|
<td>${app.name}</td>
|
||||||
|
<td>${app.url}</td>
|
||||||
|
<td><code>${app.api_key}</code></td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modals
|
||||||
|
function openModal(id) {
|
||||||
|
document.getElementById(id).classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(id) {
|
||||||
|
document.getElementById(id).classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
// User Form
|
||||||
|
const userForm = document.getElementById("user-form");
|
||||||
|
userForm.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = document.getElementById("user-id").value;
|
||||||
|
const username = document.getElementById("user-username").value;
|
||||||
|
const email = document.getElementById("user-email").value;
|
||||||
|
const password = document.getElementById("user-password").value;
|
||||||
|
const isAdmin = document.getElementById("user-is-admin").checked;
|
||||||
|
const isActive = document.getElementById("user-is-active").checked;
|
||||||
|
|
||||||
|
const data = { username, email, is_admin: isAdmin, is_active: isActive };
|
||||||
|
if (password) data.password = password;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res;
|
||||||
|
if (id) {
|
||||||
|
res = await authFetch(`${API_URL}/users/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!password) return alert("Password required for new user");
|
||||||
|
res = await authFetch(`${API_URL}/users/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal("user-modal");
|
||||||
|
loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.editUser = (user) => {
|
||||||
|
document.getElementById("user-id").value = user.id;
|
||||||
|
document.getElementById("user-username").value = user.username;
|
||||||
|
document.getElementById("user-email").value = user.email;
|
||||||
|
document.getElementById("user-password").value = "";
|
||||||
|
document.getElementById("user-is-admin").checked = user.is_admin;
|
||||||
|
document.getElementById("user-is-active").checked = user.is_active;
|
||||||
|
openModal("user-modal");
|
||||||
|
};
|
||||||
|
|
||||||
|
// App Form
|
||||||
|
const appForm = document.getElementById("app-form");
|
||||||
|
appForm.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = document.getElementById("app-name").value;
|
||||||
|
const url = document.getElementById("app-url").value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`${API_URL}/apps/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name, url })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal("app-modal");
|
||||||
|
loadApps();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assign App
|
||||||
|
window.assignAppModal = async (userId) => {
|
||||||
|
document.getElementById("assign-user-id").value = userId;
|
||||||
|
|
||||||
|
// Load apps for select
|
||||||
|
const res = await authFetch(`${API_URL}/apps/`);
|
||||||
|
const apps = await res.json();
|
||||||
|
const select = document.getElementById("assign-app-select");
|
||||||
|
select.innerHTML = '<option value="">Select Application</option>' +
|
||||||
|
apps.map(app => `<option value="${app.id}">${app.name}</option>`).join("");
|
||||||
|
|
||||||
|
openModal("assign-modal");
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignForm = document.getElementById("assign-form");
|
||||||
|
assignForm.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const userId = document.getElementById("assign-user-id").value;
|
||||||
|
const appId = document.getElementById("assign-app-select").value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`${API_URL}/users/${userId}/assign/${appId}`, {
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Failed to assign");
|
||||||
|
|
||||||
|
closeModal("assign-modal");
|
||||||
|
alert("Assigned successfully");
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start
|
||||||
|
init();
|
||||||
9
requirements.txt
Normal file
9
requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
sqlalchemy
|
||||||
|
pydantic
|
||||||
|
passlib[bcrypt]
|
||||||
|
python-multipart
|
||||||
|
python-jose[cryptography]
|
||||||
|
aiosmtplib
|
||||||
|
jinja2
|
||||||
Reference in New Issue
Block a user