a
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
"""SSO 单点登录中心 —— FastAPI"""
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, Form, HTTPException, Request, Response
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from jose import jwt, JWTError
|
||||
from passlib.context import CryptContext
|
||||
|
||||
import database
|
||||
from database import SessionLocal
|
||||
import models
|
||||
from sso_config import (
|
||||
SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
DEFAULT_USERS, COOKIE_DOMAIN, COOKIE_SAMESITE,
|
||||
)
|
||||
|
||||
app = FastAPI(title="SSO Center")
|
||||
|
||||
# CORS:允许 blog 前端 8888 调用
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://129.211.173.87:8888",
|
||||
"http://129.211.173.87:8889",
|
||||
"http://129.211.173.87:8892",
|
||||
"http://129.211.173.87:8895", # happy-games
|
||||
"http://129.211.173.87:9000",
|
||||
"http://129.211.173.87",
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
# ── 初始化数据库 ──────────────────────────
|
||||
models.Base.metadata.create_all(bind=database.engine)
|
||||
db = database.SessionLocal()
|
||||
try:
|
||||
if db.query(models.User).count() == 0:
|
||||
for uname, udata in DEFAULT_USERS.items():
|
||||
hashed = pwd_context.hash(udata["password"])
|
||||
db.add(models.User(username=uname, password=hashed))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# ── 加载静态登录页 ────────────────────────
|
||||
_static_dir = os.path.join(os.path.dirname(__file__), "static")
|
||||
_login_html_path = os.path.join(_static_dir, "login.html")
|
||||
with open(_login_html_path, "r", encoding="utf-8") as f:
|
||||
LOGIN_HTML = f.read()
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def verify_user(username: str, password: str) -> bool:
|
||||
db = database.SessionLocal()
|
||||
try:
|
||||
user = db.query(models.User).filter(models.User.username == username).first()
|
||||
if not user:
|
||||
return False
|
||||
return pwd_context.verify(password, user.password)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ── 登录页 ──────────────────────────────
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def login_page(request: Request):
|
||||
redirect = request.query_params.get("redirect", "")
|
||||
# 如果已登录(有 Cookie),直接跳转回原系统;无 redirect 则展示用户中心
|
||||
token = request.cookies.get("access_token")
|
||||
if token:
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
raise JWTError
|
||||
if redirect:
|
||||
return RedirectResponse(redirect)
|
||||
html = LOGGED_IN_HTML.replace("{{username}}", username).replace("{{redirect}}", "")
|
||||
return HTMLResponse(html)
|
||||
except JWTError:
|
||||
pass # token 无效,继续展示登录页
|
||||
|
||||
return HTMLResponse(LOGIN_HTML.replace("{{redirect}}", redirect))
|
||||
|
||||
|
||||
# ── 已登录页面 ──────────────────────────
|
||||
|
||||
LOGGED_IN_HTML = '''<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SSO 用户中心</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);min-height:100vh;display:flex;align-items:center;justify-content:center}
|
||||
.card{background:#fff;padding:40px;border-radius:16px;box-shadow:0 20px 60px rgba(0,0,0,.3);width:100%;max-width:400px}
|
||||
h1{text-align:center;margin-bottom:8px;color:#333;font-size:24px}
|
||||
p.sub{text-align:center;color:#888;margin-bottom:28px;font-size:14px}
|
||||
.input-group{margin-bottom:20px}
|
||||
label{display:block;margin-bottom:6px;color:#555;font-size:14px;font-weight:500}
|
||||
input{width:100%;padding:12px 14px;border:1px solid #ddd;border-radius:8px;font-size:15px;transition:border .2s}
|
||||
input:focus{outline:none;border-color:#667eea}
|
||||
button{width:100%;padding:14px;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:#fff;border:none;border-radius:8px;font-size:16px;cursor:pointer;transition:opacity .2s;margin-bottom:12px}
|
||||
button:hover{opacity:.9}
|
||||
button.secondary{background:#f3f4f6;color:#374151}
|
||||
button.secondary:hover{background:#e5e7eb}
|
||||
.error{background:#fee2e2;color:#991b1b;padding:12px;border-radius:8px;margin-bottom:16px;font-size:14px;display:none}
|
||||
.success{background:#d1fae5;color:#065f46;padding:12px;border-radius:8px;margin-bottom:16px;font-size:14px;display:none}
|
||||
.links{text-align:center;margin-top:16px}
|
||||
.links a{color:#667eea;text-decoration:none;font-size:14px}
|
||||
.links a:hover{text-decoration:underline}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>🔐 SSO 用户中心</h1>
|
||||
<p class="sub">欢迎,<strong>{{username}}</strong></p>
|
||||
<div class="success" id="success"></div>
|
||||
<div class="error" id="error"></div>
|
||||
<h3 style="margin-bottom:16px;color:#333;font-size:16px">修改密码</h3>
|
||||
<form id="changeForm">
|
||||
<div class="input-group">
|
||||
<label>旧密码</label>
|
||||
<input type="password" id="old_password" required>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>新密码</label>
|
||||
<input type="password" id="new_password" required minlength="6">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>确认新密码</label>
|
||||
<input type="password" id="confirm_password" required minlength="6">
|
||||
</div>
|
||||
<button type="submit">修改密码</button>
|
||||
</form>
|
||||
<button class="secondary" onclick="logout()">退出登录</button>
|
||||
<div class="links" id="redirectLink" style="display:none">
|
||||
<a id="backLink" href="#">← 返回原系统</a>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const redirect = "{{redirect}}";
|
||||
if (redirect) {
|
||||
document.getElementById('redirectLink').style.display = 'block';
|
||||
document.getElementById('backLink').href = redirect;
|
||||
}
|
||||
document.getElementById('changeForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const errorDiv = document.getElementById('error');
|
||||
const successDiv = document.getElementById('success');
|
||||
errorDiv.style.display = 'none';
|
||||
successDiv.style.display = 'none';
|
||||
const oldPassword = document.getElementById('old_password').value;
|
||||
const newPassword = document.getElementById('new_password').value;
|
||||
const confirmPassword = document.getElementById('confirm_password').value;
|
||||
if (newPassword !== confirmPassword) {
|
||||
errorDiv.textContent = '两次输入的新密码不一致';
|
||||
errorDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
const form = new URLSearchParams();
|
||||
form.append('old_password', oldPassword);
|
||||
form.append('new_password', newPassword);
|
||||
try {
|
||||
const res = await fetch('/api/change-password', {method:'POST', body:form, credentials:'include'});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
successDiv.textContent = '密码修改成功,请重新登录';
|
||||
successDiv.style.display = 'block';
|
||||
document.getElementById('changeForm').reset();
|
||||
setTimeout(() => { window.location.href = '/'; }, 1500);
|
||||
} else {
|
||||
errorDiv.textContent = data.detail || '修改失败';
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
errorDiv.textContent = '网络错误';
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
});
|
||||
async function logout() {
|
||||
await fetch('/api/logout', {method:'POST', credentials:'include'});
|
||||
window.location.href = '/';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
|
||||
# ── API ─────────────────────────────────
|
||||
|
||||
@app.post("/api/login")
|
||||
def api_login(response: Response, username: str = Form(...), password: str = Form(...), redirect: str = Form("")):
|
||||
if not verify_user(username, password):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
|
||||
access_token = create_access_token(data={"sub": username})
|
||||
|
||||
# 种 Cookie(Domain=IP,SameSite=Lax,同 IP 不同端口共享)
|
||||
cookie_opts = {
|
||||
"key": "access_token",
|
||||
"value": access_token,
|
||||
"domain": COOKIE_DOMAIN,
|
||||
"path": "/",
|
||||
"httponly": True,
|
||||
"samesite": COOKIE_SAMESITE,
|
||||
"max_age": ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
}
|
||||
response.set_cookie(**cookie_opts)
|
||||
|
||||
return {"success": True, "username": username, "redirect": redirect}
|
||||
|
||||
|
||||
@app.post("/api/logout")
|
||||
def api_logout(response: Response):
|
||||
response.delete_cookie("access_token", domain=COOKIE_DOMAIN, path="/")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@app.post("/api/change-password")
|
||||
def api_change_password(
|
||||
request: Request,
|
||||
response: Response,
|
||||
username: str = Form(""),
|
||||
old_password: str = Form(...),
|
||||
new_password: str = Form(...)
|
||||
):
|
||||
"""修改密码 —— 支持两种身份验证方式:
|
||||
1) Cookie 中的 token(已登录状态)
|
||||
2) 提交 username + old_password(未登录状态,登录页直接修改)
|
||||
成功后清除 Cookie 要求重新登录
|
||||
"""
|
||||
# 1) 确定用户名
|
||||
target_username = None
|
||||
|
||||
# 先尝试 Cookie
|
||||
token = request.cookies.get("access_token")
|
||||
if token:
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
target_username = payload.get("sub")
|
||||
except JWTError:
|
||||
pass
|
||||
|
||||
# 无 Cookie 则使用提交的 username + old_password 验证
|
||||
if not target_username:
|
||||
if not username:
|
||||
raise HTTPException(status_code=401, detail="未登录,请提供用户名")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.query(models.User).filter(models.User.username == username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
if not pwd_context.verify(old_password, user.password):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
target_username = username
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# 2) 验证旧密码并更新
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.query(models.User).filter(models.User.username == target_username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
if not pwd_context.verify(old_password, user.password):
|
||||
raise HTTPException(status_code=400, detail="旧密码错误")
|
||||
user.password = pwd_context.hash(new_password)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# 3) 清除 Cookie,强制重新登录
|
||||
response.delete_cookie("access_token", domain=COOKIE_DOMAIN, path="/")
|
||||
return {"success": True, "message": "密码修改成功,请重新登录"}
|
||||
|
||||
|
||||
@app.get("/api/userinfo")
|
||||
def api_userinfo(request: Request):
|
||||
token = request.cookies.get("access_token")
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
raise HTTPException(status_code=401, detail="无效令牌")
|
||||
return {"username": username}
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=401, detail="令牌已过期或无效")
|
||||
|
||||
|
||||
@app.get("/api/validate")
|
||||
def api_validate(request: Request):
|
||||
"""供业务系统可选调用:验证 Cookie 中的 token"""
|
||||
try:
|
||||
return api_userinfo(request)
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=9000)
|
||||
Reference in New Issue
Block a user