diff --git a/Dockerfile b/Dockerfile index 3b72f97..ddb411f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,5 +42,11 @@ COPY requirements.txt ./ COPY entrypoint.sh . RUN chmod +x entrypoint.sh +# OIDC-only mode: when "true", password login is rejected, user creation +# ignores passwords (passwordless users that sign in via a bound OIDC +# identity / API keys). Overridable at runtime via the same env var. +ARG HARBORFORGE_OIDC_ONLY=false +ENV HARBORFORGE_OIDC_ONLY=${HARBORFORGE_OIDC_ONLY} + EXPOSE 8000 ENTRYPOINT ["./entrypoint.sh"] diff --git a/app/api/routers/auth.py b/app/api/routers/auth.py index 825675c..24b9097 100644 --- a/app/api/routers/auth.py +++ b/app/api/routers/auth.py @@ -18,6 +18,8 @@ router = APIRouter(prefix="/auth", tags=["Auth"]) @router.post("/token", response_model=Token) async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): + if settings.HARBORFORGE_OIDC_ONLY: + raise HTTPException(status_code=403, detail="Password login is disabled (OIDC only)") user = db.query(models.User).filter(models.User.username == form_data.username).first() if not user or not verify_password(form_data.password, user.hashed_password or ""): raise HTTPException(status_code=401, detail="Incorrect username or password", diff --git a/app/api/routers/oidc.py b/app/api/routers/oidc.py new file mode 100644 index 0000000..00cc048 --- /dev/null +++ b/app/api/routers/oidc.py @@ -0,0 +1,145 @@ +"""OIDC (OpenID Connect) login + public auth-config. + +Generic OIDC via discovery. The backend performs the Authorization Code +flow, then issues its own existing HS256 JWT (same as password login) so +the rest of the app is unchanged. + +Sign-in policy: an OIDC identity must already be bound to an hf user +(see PUT /users/{id}/oidc-binding). Unbound identities are rejected — no +auto-provisioning. +""" +from datetime import timedelta +from urllib.parse import urlencode + +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session + +from app.core.config import get_db, settings +from app.models import models +from app.api.deps import create_access_token, get_current_user + +router = APIRouter(prefix="/auth", tags=["Auth"]) + +# Authlib registry — only configured when OIDC is enabled. +_oauth = None + + +def _get_oidc(): + """Lazily build the Authlib OIDC client; 503 if not configured.""" + global _oauth + if not (settings.OIDC_ENABLED and settings.OIDC_ISSUER and settings.OIDC_CLIENT_ID): + raise HTTPException(status_code=503, detail="OIDC is not configured") + if _oauth is None: + from authlib.integrations.starlette_client import OAuth + oauth = OAuth() + oauth.register( + name="oidc", + server_metadata_url=settings.OIDC_ISSUER.rstrip("/") + "/.well-known/openid-configuration", + client_id=settings.OIDC_CLIENT_ID, + client_secret=settings.OIDC_CLIENT_SECRET, + client_kwargs={"scope": settings.OIDC_SCOPES}, + ) + _oauth = oauth + return _oauth.oidc + + +def _frontend(suffix_qs: dict | None = None, fragment: str | None = None) -> str: + """Build the post-login frontend redirect (never client-controlled).""" + base = settings.OIDC_POST_LOGIN_REDIRECT or "/" + url = base + if suffix_qs: + url += ("&" if "?" in base else "?") + urlencode(suffix_qs) + if fragment: + url += "#" + fragment + return url + + +@router.get("/config") +def auth_config(): + """Public: lets the frontend decide which login UI to render.""" + return { + "oidc_enabled": bool(settings.OIDC_ENABLED and settings.OIDC_ISSUER and settings.OIDC_CLIENT_ID), + "oidc_only": bool(settings.HARBORFORGE_OIDC_ONLY), + "password_login": not bool(settings.HARBORFORGE_OIDC_ONLY), + "oidc_login_url": "/auth/oidc/login", + } + + +@router.get("/oidc/login") +async def oidc_login(request: Request): + """Start the OIDC Authorization Code flow for sign-in.""" + oidc = _get_oidc() + request.session.pop("hf_oidc_mode", None) + request.session.pop("hf_oidc_uid", None) + request.session["hf_oidc_mode"] = "login" + return await oidc.authorize_redirect(request, settings.OIDC_REDIRECT_URI) + + +@router.get("/oidc/link") +async def oidc_link(request: Request, current_user: models.User = Depends(get_current_user)): + """Self-service: bind the caller's own account to an OIDC identity. + + Only available when NOT in OIDC-only mode (admins use the binding API + in that mode).""" + if settings.HARBORFORGE_OIDC_ONLY: + raise HTTPException(status_code=403, detail="Self-service linking is disabled in OIDC-only mode") + oidc = _get_oidc() + request.session["hf_oidc_mode"] = "link" + request.session["hf_oidc_uid"] = current_user.id + return await oidc.authorize_redirect(request, settings.OIDC_REDIRECT_URI) + + +@router.get("/oidc/callback") +async def oidc_callback(request: Request, db: Session = Depends(get_db)): + """OIDC redirect target. Resolves identity → hf user (must be bound).""" + oidc = _get_oidc() + mode = request.session.pop("hf_oidc_mode", "login") + link_uid = request.session.pop("hf_oidc_uid", None) + try: + token = await oidc.authorize_access_token(request) + except Exception: + return RedirectResponse(_frontend({"oidc_error": "exchange_failed"})) + + claims = token.get("userinfo") or {} + if not claims: + try: + claims = await oidc.userinfo(token=token) + except Exception: + claims = {} + subject = claims.get("sub") + issuer = claims.get("iss") or settings.OIDC_ISSUER + if not subject: + return RedirectResponse(_frontend({"oidc_error": "no_subject"})) + + if mode == "link": + if settings.HARBORFORGE_OIDC_ONLY or link_uid is None: + return RedirectResponse(_frontend({"oidc_error": "link_not_allowed"})) + user = db.query(models.User).filter(models.User.id == link_uid).first() + if not user: + return RedirectResponse(_frontend({"oidc_error": "user_gone"})) + clash = db.query(models.User).filter( + models.User.oidc_issuer == issuer, + models.User.oidc_subject == subject, + models.User.id != user.id, + ).first() + if clash: + return RedirectResponse(_frontend({"oidc_error": "already_bound"})) + user.oidc_issuer = issuer + user.oidc_subject = subject + db.commit() + return RedirectResponse(_frontend({"oidc_linked": "1"})) + + # sign-in: identity must already be bound to an active hf user + user = db.query(models.User).filter( + models.User.oidc_issuer == issuer, + models.User.oidc_subject == subject, + ).first() + if not user or not user.is_active or user.username in ("acc-mgr", "deleted-user"): + return RedirectResponse(_frontend({"oidc_error": "not_linked"})) + + access_token = create_access_token( + data={"sub": str(user.id)}, + expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES), + ) + return RedirectResponse(_frontend(fragment=urlencode({"token": access_token}))) diff --git a/app/api/routers/users.py b/app/api/routers/users.py index 22df8f1..b0a84db 100644 --- a/app/api/routers/users.py +++ b/app/api/routers/users.py @@ -8,7 +8,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.api.deps import get_current_user, get_current_user_or_apikey, get_password_hash -from app.core.config import get_db +from app.core.config import get_db, settings from app.init_wizard import DELETED_USER_USERNAME from app.models import models from app.models.agent import Agent @@ -32,6 +32,8 @@ def _user_response(user: models.User) -> dict: "role_name": user.role_name, "agent_id": user.agent.agent_id if user.agent else None, "discord_user_id": user.discord_user_id, + "oidc_issuer": user.oidc_issuer, + "oidc_subject": user.oidc_subject, "created_at": user.created_at, } return data @@ -111,7 +113,13 @@ def create_user( raise HTTPException(status_code=400, detail="agent_id already in use") assigned_role = _resolve_user_role(db, user.role_id) - hashed_password = get_password_hash(user.password) if user.password else None + # In OIDC-only mode, ignore any supplied password: the user is created + # passwordless (cannot password-login) and is expected to sign in via a + # bound OIDC identity. API keys still work for such users. + if settings.HARBORFORGE_OIDC_ONLY: + hashed_password = None + else: + hashed_password = get_password_hash(user.password) if user.password else None db_user = models.User( username=user.username, email=user.email, @@ -191,7 +199,7 @@ def update_user( if payload.full_name is not None: user.full_name = payload.full_name - if payload.password is not None and payload.password.strip(): + if payload.password is not None and payload.password.strip() and not settings.HARBORFORGE_OIDC_ONLY: user.hashed_password = get_password_hash(payload.password) if payload.role_id is not None: @@ -414,3 +422,68 @@ def list_user_worklogs( if current_user.id != user.id and not current_user.is_admin: raise HTTPException(status_code=403, detail="Forbidden") return db.query(WorkLog).filter(WorkLog.user_id == user.id).order_by(WorkLog.logged_date.desc()).limit(limit).all() + + +# ---- OIDC identity binding ------------------------------------------------ + +class OidcBindingRequest(BaseModel): + issuer: str + subject: str + + +class OidcBindingResponse(BaseModel): + user_id: int + username: str + oidc_issuer: str | None = None + oidc_subject: str | None = None + + +@router.put("/{identifier}/oidc-binding", response_model=OidcBindingResponse) +def bind_user_oidc( + identifier: str, + payload: OidcBindingRequest, + db: Session = Depends(get_db), + _: models.User = Depends(require_account_creator), +): + """Bind an hf user to an external OIDC identity (issuer + subject). + + Admin or account-manager only (JWT or API key). One OIDC identity maps + to at most one user.""" + issuer = (payload.issuer or "").strip() + subject = (payload.subject or "").strip() + if not issuer or not subject: + raise HTTPException(status_code=400, detail="issuer and subject are required") + user = _find_user_by_id_or_username(db, identifier) + if not user: + raise HTTPException(status_code=404, detail="User not found") + clash = db.query(models.User).filter( + models.User.oidc_issuer == issuer, + models.User.oidc_subject == subject, + models.User.id != user.id, + ).first() + if clash: + raise HTTPException(status_code=409, detail=f"OIDC identity already bound to user '{clash.username}'") + user.oidc_issuer = issuer + user.oidc_subject = subject + db.commit() + db.refresh(user) + return OidcBindingResponse(user_id=user.id, username=user.username, + oidc_issuer=user.oidc_issuer, oidc_subject=user.oidc_subject) + + +@router.delete("/{identifier}/oidc-binding", response_model=OidcBindingResponse) +def unbind_user_oidc( + identifier: str, + db: Session = Depends(get_db), + _: models.User = Depends(require_account_creator), +): + """Remove a user's OIDC binding. Admin or account-manager only.""" + user = _find_user_by_id_or_username(db, identifier) + if not user: + raise HTTPException(status_code=404, detail="User not found") + user.oidc_issuer = None + user.oidc_subject = None + db.commit() + db.refresh(user) + return OidcBindingResponse(user_id=user.id, username=user.username, + oidc_issuer=None, oidc_subject=None) diff --git a/app/core/config.py b/app/core/config.py index e011199..27d1c1d 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -38,6 +38,20 @@ class Settings(BaseSettings): ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + # --- OIDC (generic, OpenID Connect discovery) --- + OIDC_ENABLED: bool = False + OIDC_ISSUER: str = "" # e.g. https://idp.example.com (we use {issuer}/.well-known/openid-configuration) + OIDC_CLIENT_ID: str = "" + OIDC_CLIENT_SECRET: str = "" + OIDC_REDIRECT_URI: str = "" # backend callback, e.g. https://hf-api.example.com/auth/oidc/callback + OIDC_SCOPES: str = "openid email profile" + OIDC_POST_LOGIN_REDIRECT: str = "" # frontend URL to return to (token in fragment). Falls back to "/" + + # When true: no password login at all. Password login endpoint rejects, + # user creation ignores any password (passwordless user that can only use + # API keys / OIDC), and the frontend hides all password UI. + HARBORFORGE_OIDC_ONLY: bool = False + class Config: env_file = ".env" diff --git a/app/main.py b/app/main.py index 0baa3c0..de2eacd 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,9 @@ """HarborForge API — Agent/人类协同任务管理平台""" from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.sessions import SessionMiddleware + +from app.core.config import settings app = FastAPI( title="HarborForge API", @@ -17,6 +20,17 @@ app.add_middleware( allow_headers=["*"], ) +# Short-lived signed session cookie — only used to carry the OIDC +# state/nonce between /auth/oidc/login and the callback. +app.add_middleware( + SessionMiddleware, + secret_key=settings.SECRET_KEY, + session_cookie="hf_oidc", + same_site="lax", + https_only=False, + max_age=600, +) + # Health & version (kept at top level) @app.get("/health", tags=["System"]) def health_check(): @@ -65,8 +79,10 @@ from app.api.routers.meetings import router as meetings_router from app.api.routers.essentials import router as essentials_router from app.api.routers.schedule_type import router as schedule_type_router from app.api.routers.calendar import router as calendar_router +from app.api.routers.oidc import router as oidc_router app.include_router(auth_router) +app.include_router(oidc_router) app.include_router(tasks_router) app.include_router(projects_router) app.include_router(users_router) @@ -277,6 +293,14 @@ def _migrate_schema(): if _has_table(db, "users") and not _has_column(db, "users", "discord_user_id"): db.execute(text("ALTER TABLE users ADD COLUMN discord_user_id VARCHAR(32) NULL")) + # --- users OIDC binding (issuer + subject), unique together --- + if _has_table(db, "users") and not _has_column(db, "users", "oidc_issuer"): + db.execute(text("ALTER TABLE users ADD COLUMN oidc_issuer VARCHAR(255) NULL")) + if _has_table(db, "users") and not _has_column(db, "users", "oidc_subject"): + db.execute(text("ALTER TABLE users ADD COLUMN oidc_subject VARCHAR(255) NULL")) + if _has_table(db, "users") and _has_column(db, "users", "oidc_subject"): + _ensure_unique_index(db, "users", "uq_users_oidc_identity", "oidc_issuer, oidc_subject") + # --- monitored_servers.api_key for heartbeat v2 --- if _has_table(db, "monitored_servers") and not _has_column(db, "monitored_servers", "api_key"): db.execute(text("ALTER TABLE monitored_servers ADD COLUMN api_key VARCHAR(64) NULL")) diff --git a/app/models/models.py b/app/models/models.py index 8e05ca1..db1cd00 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean, JSON, Time +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean, JSON, Time, UniqueConstraint from sqlalchemy.orm import relationship from sqlalchemy.sql import func from app.core.config import Base @@ -66,6 +66,9 @@ class Project(Base): class User(Base): __tablename__ = "users" + __table_args__ = ( + UniqueConstraint("oidc_issuer", "oidc_subject", name="uq_users_oidc_identity"), + ) id = Column(Integer, primary_key=True, index=True) username = Column(String(50), unique=True, nullable=False) @@ -73,6 +76,10 @@ class User(Base): hashed_password = Column(String(255), nullable=True) full_name = Column(String(100), nullable=True) discord_user_id = Column(String(32), nullable=True) + # OIDC binding: an hf user is linked to at most one external OIDC identity + # (issuer + subject). Unique together so one IdP identity maps to one user. + oidc_issuer = Column(String(255), nullable=True) + oidc_subject = Column(String(255), nullable=True) is_active = Column(Boolean, default=True) is_admin = Column(Boolean, default=False) role_id = Column(Integer, ForeignKey("roles.id"), nullable=True) diff --git a/app/schemas/schemas.py b/app/schemas/schemas.py index cec1e59..5f3ebe6 100644 --- a/app/schemas/schemas.py +++ b/app/schemas/schemas.py @@ -194,8 +194,10 @@ class UserResponse(UserBase): role_name: Optional[str] = None agent_id: Optional[str] = None discord_user_id: Optional[str] = None + oidc_issuer: Optional[str] = None + oidc_subject: Optional[str] = None created_at: datetime - + class Config: from_attributes = True diff --git a/requirements.txt b/requirements.txt index 85208cb..5d79515 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,3 +12,5 @@ alembic==1.13.1 python-dotenv==1.0.0 httpx==0.27.0 requests==2.31.0 +authlib==1.3.2 +itsdangerous==2.2.0