f69efb67ad
- Tauri desktop app (apps/desktop) - Python Django API service (services/api) - Deployment scripts and docs
590 lines
20 KiB
Python
590 lines
20 KiB
Python
import json
|
|
|
|
from django.conf import settings
|
|
from django.http import HttpRequest, JsonResponse, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect
|
|
from django.utils import timezone
|
|
from django.views.decorators.clickjacking import xframe_options_exempt
|
|
from django.views.decorators.csrf import csrf_exempt
|
|
from django.views.decorators.http import require_GET, require_http_methods, require_POST
|
|
|
|
from .models import DesktopLoginSession, LoginCode, User
|
|
from .services.feishu_oauth import (
|
|
build_login_url,
|
|
build_qr_goto,
|
|
build_qr_login_config,
|
|
build_tauri_callback,
|
|
create_desktop_login_session,
|
|
exchange_tmp_code_for_oauth_code,
|
|
handle_feishu_callback,
|
|
)
|
|
from .services.jwt_service import issue_jwt
|
|
from vpn.services.offboarding import revoke_user_vpn_access
|
|
|
|
|
|
class TauriDeepLinkRedirect(HttpResponseRedirect):
|
|
allowed_schemes = HttpResponseRedirect.allowed_schemes + [settings.TAURI_DEEP_LINK_SCHEME]
|
|
|
|
|
|
@require_GET
|
|
def feishu_login_url(_request: HttpRequest) -> JsonResponse:
|
|
return JsonResponse(build_login_url())
|
|
|
|
|
|
@require_GET
|
|
def feishu_qr_config(_request: HttpRequest) -> JsonResponse:
|
|
return JsonResponse(build_qr_login_config())
|
|
|
|
|
|
@require_GET
|
|
def feishu_qr_page(_request: HttpRequest) -> HttpResponse:
|
|
config = build_qr_login_config()
|
|
html = f"""<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>AbyssInfo VPN 飞书扫码登录</title>
|
|
<style>
|
|
* {{ box-sizing: border-box; }}
|
|
html {{
|
|
margin: 0;
|
|
min-height: 100%;
|
|
overflow: hidden;
|
|
}}
|
|
body {{
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
display: grid;
|
|
place-items: center;
|
|
background: linear-gradient(180deg, #f7f9ff 0%, #eef3ff 100%);
|
|
color: #1f2329;
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
|
|
}}
|
|
main {{
|
|
width: min(380px, calc(100vw - 32px));
|
|
padding: 28px 22px 24px;
|
|
border: 1px solid #dee0e3;
|
|
border-radius: 22px;
|
|
background: #fff;
|
|
box-shadow: 0 18px 42px rgba(31, 35, 41, .1);
|
|
text-align: center;
|
|
}}
|
|
h1 {{ margin: 0; font-size: 24px; line-height: 32px; }}
|
|
p {{ margin: 10px 0 0; color: #646a73; font-size: 14px; line-height: 22px; }}
|
|
#login_container {{
|
|
width: 280px;
|
|
height: 280px;
|
|
display: grid;
|
|
place-items: center;
|
|
margin: 24px auto 0;
|
|
border: 1px solid #edf0f5;
|
|
border-radius: 18px;
|
|
overflow: hidden;
|
|
}}
|
|
.hint {{ margin-top: 16px; font-size: 13px; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1>飞书扫码登录</h1>
|
|
<p>请使用飞书 App 扫描二维码。授权完成后会自动回到 AbyssInfo VPN。</p>
|
|
<div id="login_container"></div>
|
|
<p class="hint">二维码 5 分钟内有效,过期请刷新页面。</p>
|
|
</main>
|
|
<script src="https://lf-package-cn.feishucdn.com/obj/feishu-static/lark/passport/qrcode/LarkSSOSDKWebQRCode-1.0.3.js"></script>
|
|
<script>
|
|
const goto = {json.dumps(config["goto"])};
|
|
const qr = QRLogin({{
|
|
id: "login_container",
|
|
goto,
|
|
width: 280,
|
|
height: 280,
|
|
style: "border:none"
|
|
}});
|
|
window.addEventListener("message", function(event) {{
|
|
if (!qr.matchOrigin(event.origin) || !qr.matchData(event.data)) {{
|
|
return;
|
|
}}
|
|
const tmpCode = event.data && event.data.tmp_code;
|
|
if (!tmpCode) {{
|
|
return;
|
|
}}
|
|
window.location.href = goto + "&tmp_code=" + encodeURIComponent(tmpCode);
|
|
}});
|
|
</script>
|
|
</body>
|
|
</html>"""
|
|
return HttpResponse(html)
|
|
|
|
|
|
@csrf_exempt
|
|
@require_POST
|
|
def desktop_session_create(_request: HttpRequest) -> JsonResponse:
|
|
session = create_desktop_login_session()
|
|
return JsonResponse(
|
|
{
|
|
"session_id": session.session_id,
|
|
"goto": build_qr_goto(session.state),
|
|
"state": session.state,
|
|
"expires_in": settings.OAUTH_STATE_EXPIRES_SECONDS,
|
|
}
|
|
)
|
|
|
|
|
|
@require_GET
|
|
def desktop_session_status(_request: HttpRequest, session_id: str) -> JsonResponse:
|
|
session = DesktopLoginSession.objects.select_related("user").filter(session_id=session_id).first()
|
|
if session is None:
|
|
return JsonResponse({"error": "session_not_found"}, status=404)
|
|
if session.is_expired and session.status == DesktopLoginSession.STATUS_PENDING:
|
|
session.status = DesktopLoginSession.STATUS_EXPIRED
|
|
session.error = "session_expired"
|
|
session.save(update_fields=["status", "error", "updated_at"])
|
|
if session.status != DesktopLoginSession.STATUS_AUTHENTICATED:
|
|
return JsonResponse(
|
|
{
|
|
"status": session.status,
|
|
"error": session.error,
|
|
"expires_at": session.expires_at.isoformat(),
|
|
}
|
|
)
|
|
if session.user is None or session.user.status != session.user.STATUS_ACTIVE:
|
|
return JsonResponse({"status": DesktopLoginSession.STATUS_FAILED, "error": "user_disabled"}, status=403)
|
|
return JsonResponse(
|
|
{
|
|
"status": session.status,
|
|
"token": issue_jwt(session.user.id),
|
|
"expires_in": settings.JWT_EXPIRES_SECONDS,
|
|
"user": {
|
|
"id": session.user.id,
|
|
"name": session.user.name,
|
|
"email": session.user.email,
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
@csrf_exempt
|
|
@require_POST
|
|
def desktop_session_complete(request: HttpRequest, session_id: str) -> JsonResponse:
|
|
session = DesktopLoginSession.objects.select_related("user").filter(session_id=session_id).first()
|
|
if session is None:
|
|
return JsonResponse({"error": "session_not_found"}, status=404)
|
|
if session.is_expired:
|
|
session.status = DesktopLoginSession.STATUS_EXPIRED
|
|
session.error = "session_expired"
|
|
session.save(update_fields=["status", "error", "updated_at"])
|
|
return JsonResponse({"error": "session_expired"}, status=401)
|
|
try:
|
|
body = json.loads(request.body or b"{}")
|
|
except json.JSONDecodeError:
|
|
return JsonResponse({"error": "invalid_json"}, status=400)
|
|
tmp_code = body.get("tmp_code") or ""
|
|
code = body.get("code") or ""
|
|
if not code:
|
|
if not tmp_code:
|
|
return JsonResponse({"error": "missing_tmp_code"}, status=400)
|
|
try:
|
|
code = exchange_tmp_code_for_oauth_code(tmp_code, session.state)
|
|
except Exception as error:
|
|
session.status = DesktopLoginSession.STATUS_FAILED
|
|
session.error = str(error)[:255]
|
|
session.save(update_fields=["status", "error", "updated_at"])
|
|
return JsonResponse({"error": str(error)}, status=400)
|
|
try:
|
|
login_code = handle_feishu_callback(code, session.state)
|
|
except PermissionError:
|
|
session.status = DesktopLoginSession.STATUS_FAILED
|
|
session.error = "user_disabled"
|
|
session.save(update_fields=["status", "error", "updated_at"])
|
|
return JsonResponse({"error": "user_disabled"}, status=403)
|
|
except Exception as error:
|
|
session.status = DesktopLoginSession.STATUS_FAILED
|
|
session.error = str(error)[:255]
|
|
session.save(update_fields=["status", "error", "updated_at"])
|
|
return JsonResponse({"error": str(error)}, status=400)
|
|
session.user = login_code.user
|
|
session.status = DesktopLoginSession.STATUS_AUTHENTICATED
|
|
session.error = ""
|
|
session.authenticated_at = timezone.now()
|
|
session.save(update_fields=["user", "status", "error", "authenticated_at", "updated_at"])
|
|
return JsonResponse({"status": session.status})
|
|
|
|
|
|
@xframe_options_exempt
|
|
@require_GET
|
|
def desktop_session_qr_page(request: HttpRequest, session_id: str) -> HttpResponse:
|
|
session = DesktopLoginSession.objects.filter(session_id=session_id).first()
|
|
if session is None:
|
|
return HttpResponseBadRequest("session_not_found")
|
|
if session.is_expired:
|
|
return HttpResponseBadRequest("session_expired")
|
|
embedded = request.GET.get("embedded") == "1"
|
|
body_class = "embedded" if embedded else ""
|
|
goto = build_qr_goto(session.state)
|
|
html = f"""<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>AbyssInfo VPN 飞书扫码登录</title>
|
|
<style>
|
|
* {{ box-sizing: border-box; }}
|
|
body {{
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
display: grid;
|
|
place-items: center;
|
|
background: linear-gradient(180deg, #f7f9ff 0%, #eef3ff 100%);
|
|
color: #1f2329;
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
|
|
}}
|
|
main {{
|
|
width: min(380px, calc(100vw - 32px));
|
|
padding: 28px 22px 24px;
|
|
border: 1px solid #dee0e3;
|
|
border-radius: 22px;
|
|
background: #fff;
|
|
box-shadow: 0 18px 42px rgba(31, 35, 41, .1);
|
|
text-align: center;
|
|
}}
|
|
h1 {{ margin: 0; font-size: 24px; line-height: 32px; }}
|
|
p {{ margin: 10px 0 0; color: #646a73; font-size: 14px; line-height: 22px; }}
|
|
#login_container {{
|
|
width: 280px;
|
|
height: 280px;
|
|
display: grid;
|
|
place-items: center;
|
|
margin: 24px auto 0;
|
|
border: 1px solid #edf0f5;
|
|
border-radius: 18px;
|
|
overflow: hidden;
|
|
}}
|
|
.hint {{ margin-top: 16px; font-size: 13px; }}
|
|
body.embedded {{
|
|
width: 260px;
|
|
height: 260px;
|
|
min-height: 260px;
|
|
overflow: hidden;
|
|
background: #fff;
|
|
}}
|
|
body.embedded main {{
|
|
width: 260px;
|
|
height: 260px;
|
|
min-height: 260px;
|
|
display: grid;
|
|
place-items: center;
|
|
padding: 0;
|
|
border: 0;
|
|
border-radius: 0;
|
|
box-shadow: none;
|
|
overflow: hidden;
|
|
}}
|
|
body.embedded h1,
|
|
body.embedded p {{
|
|
display: none;
|
|
}}
|
|
body.embedded #login_container {{
|
|
width: 260px;
|
|
height: 260px;
|
|
margin: 0;
|
|
border: 0;
|
|
border-radius: 0;
|
|
overflow: hidden;
|
|
}}
|
|
body.embedded #login_container iframe {{
|
|
display: block;
|
|
width: 260px;
|
|
height: 260px;
|
|
overflow: hidden;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body class="{body_class}">
|
|
<main>
|
|
<h1>飞书扫码登录</h1>
|
|
<p>请使用飞书 App 扫描二维码。授权完成后可回到 AbyssInfo VPN。</p>
|
|
<div id="login_container"></div>
|
|
<p class="hint">二维码 5 分钟内有效,过期请回到客户端刷新。</p>
|
|
</main>
|
|
<script src="https://lf-package-cn.feishucdn.com/obj/feishu-static/lark/passport/qrcode/LarkSSOSDKWebQRCode-1.0.3.js"></script>
|
|
<script>
|
|
const goto = {json.dumps(goto)};
|
|
const qr = QRLogin({{
|
|
id: "login_container",
|
|
goto,
|
|
width: {260 if embedded else 280},
|
|
height: {260 if embedded else 280},
|
|
style: "border:none"
|
|
}});
|
|
window.addEventListener("message", function(event) {{
|
|
if (!qr.matchOrigin(event.origin) || !qr.matchData(event.data)) {{
|
|
return;
|
|
}}
|
|
const tmpCode = event.data && event.data.tmp_code;
|
|
if (!tmpCode) {{
|
|
return;
|
|
}}
|
|
window.location.href = goto + "&tmp_code=" + encodeURIComponent(tmpCode);
|
|
}});
|
|
</script>
|
|
</body>
|
|
</html>"""
|
|
return HttpResponse(html)
|
|
|
|
|
|
def desktop_session_success_page() -> HttpResponse:
|
|
return HttpResponse(
|
|
"""<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>AbyssInfo VPN 登录成功</title>
|
|
<style>
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
display: grid;
|
|
place-items: center;
|
|
background: #f5f7ff;
|
|
color: #1f2329;
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
|
|
}
|
|
main {
|
|
width: min(360px, calc(100vw - 32px));
|
|
padding: 28px 22px;
|
|
border: 1px solid #dee0e3;
|
|
border-radius: 18px;
|
|
background: #fff;
|
|
text-align: center;
|
|
box-shadow: 0 18px 42px rgba(31, 35, 41, .1);
|
|
}
|
|
h1 { margin: 0; font-size: 24px; }
|
|
p { margin: 12px 0 0; color: #646a73; line-height: 24px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1>登录成功</h1>
|
|
<p>可以回到 AbyssInfo VPN 客户端。</p>
|
|
</main>
|
|
</body>
|
|
</html>"""
|
|
)
|
|
|
|
|
|
@require_GET
|
|
def feishu_callback(request: HttpRequest) -> HttpResponseRedirect:
|
|
code = request.GET.get("code", "")
|
|
state = request.GET.get("state", "")
|
|
desktop_session = DesktopLoginSession.objects.filter(state=state).first()
|
|
try:
|
|
login_code = handle_feishu_callback(code, state)
|
|
except PermissionError:
|
|
if desktop_session:
|
|
desktop_session.status = DesktopLoginSession.STATUS_FAILED
|
|
desktop_session.error = "user_disabled"
|
|
desktop_session.save(update_fields=["status", "error", "updated_at"])
|
|
return HttpResponseBadRequest("user_disabled")
|
|
except Exception as error:
|
|
if desktop_session:
|
|
desktop_session.status = DesktopLoginSession.STATUS_FAILED
|
|
desktop_session.error = str(error)[:255]
|
|
desktop_session.save(update_fields=["status", "error", "updated_at"])
|
|
return HttpResponseBadRequest(str(error))
|
|
if desktop_session:
|
|
desktop_session.user = login_code.user
|
|
desktop_session.status = DesktopLoginSession.STATUS_AUTHENTICATED
|
|
desktop_session.error = ""
|
|
desktop_session.authenticated_at = timezone.now()
|
|
desktop_session.save(update_fields=["user", "status", "error", "authenticated_at", "updated_at"])
|
|
return desktop_session_success_page()
|
|
redirect_url = build_tauri_callback(login_code.code, state)
|
|
return TauriDeepLinkRedirect(redirect_url)
|
|
|
|
|
|
@csrf_exempt
|
|
@require_http_methods(["GET", "POST"])
|
|
def feishu_event_callback(request: HttpRequest) -> JsonResponse:
|
|
if request.method == "GET":
|
|
return JsonResponse({"status": "ok"})
|
|
|
|
try:
|
|
body = json.loads(request.body or b"{}")
|
|
except json.JSONDecodeError:
|
|
return JsonResponse({"status": "ok"})
|
|
|
|
challenge = body.get("challenge")
|
|
if challenge:
|
|
if settings.FEISHU_EVENT_VERIFICATION_TOKEN and not _feishu_event_token_is_valid(body):
|
|
return JsonResponse({"error": "invalid_event_token"}, status=403)
|
|
return JsonResponse({"challenge": challenge})
|
|
|
|
if not _feishu_event_token_is_valid(body):
|
|
return JsonResponse({"error": "invalid_event_token"}, status=403)
|
|
|
|
event_type = _extract_feishu_event_type(body)
|
|
if not _is_feishu_user_disabled_event(event_type, body):
|
|
return JsonResponse({"status": "ignored", "event_type": event_type})
|
|
|
|
open_id = _extract_feishu_open_id(body)
|
|
if not open_id:
|
|
return JsonResponse({"status": "ignored", "event_type": event_type, "reason": "missing_open_id"})
|
|
|
|
user = User.objects.filter(feishu_open_id=open_id).first()
|
|
if user is None:
|
|
return JsonResponse({"status": "ok", "event_type": event_type, "action": "user_not_found"})
|
|
|
|
summary = revoke_user_vpn_access(
|
|
user,
|
|
reason=event_type or "feishu_user_disabled",
|
|
source="feishu",
|
|
event=_build_feishu_event_audit_detail(body, event_type, open_id),
|
|
)
|
|
return JsonResponse(
|
|
{
|
|
"status": "ok",
|
|
"event_type": event_type,
|
|
"action": "user_disabled",
|
|
"summary": summary,
|
|
}
|
|
)
|
|
|
|
|
|
def _feishu_event_token_is_valid(body: dict) -> bool:
|
|
expected = settings.FEISHU_EVENT_VERIFICATION_TOKEN
|
|
if not expected:
|
|
return bool(settings.DEBUG)
|
|
header = body.get("header") if isinstance(body.get("header"), dict) else {}
|
|
token = body.get("token") or header.get("token")
|
|
return token == expected
|
|
|
|
|
|
def _extract_feishu_event_type(body: dict) -> str:
|
|
header = body.get("header") if isinstance(body.get("header"), dict) else {}
|
|
event = body.get("event") if isinstance(body.get("event"), dict) else {}
|
|
return (
|
|
header.get("event_type")
|
|
or event.get("type")
|
|
or body.get("event_type")
|
|
or body.get("type")
|
|
or ""
|
|
)
|
|
|
|
|
|
def _extract_feishu_open_id(body: dict) -> str:
|
|
event = body.get("event") if isinstance(body.get("event"), dict) else {}
|
|
candidates = [
|
|
event.get("open_id"),
|
|
_nested_get(event, "user", "open_id"),
|
|
_nested_get(event, "user_id", "open_id"),
|
|
_nested_get(event, "object", "open_id"),
|
|
_nested_get(event, "object", "user_id", "open_id"),
|
|
_nested_get(event, "employee", "open_id"),
|
|
_nested_get(body, "open_id"),
|
|
event.get("union_id"),
|
|
]
|
|
for value in candidates:
|
|
if isinstance(value, str) and value.strip():
|
|
return value.strip()
|
|
return ""
|
|
|
|
|
|
def _nested_get(value: dict, *keys: str):
|
|
current = value
|
|
for key in keys:
|
|
if not isinstance(current, dict):
|
|
return None
|
|
current = current.get(key)
|
|
return current
|
|
|
|
|
|
def _is_feishu_user_disabled_event(event_type: str, body: dict) -> bool:
|
|
normalized = (event_type or "").lower()
|
|
if normalized in {
|
|
"contact.user.deleted_v3",
|
|
"contact.user.deleted",
|
|
"user.deleted",
|
|
"user_deleted",
|
|
}:
|
|
return True
|
|
if "user" in normalized and any(keyword in normalized for keyword in ("delete", "disabled", "disable", "frozen", "resigned")):
|
|
return True
|
|
|
|
event = body.get("event") if isinstance(body.get("event"), dict) else {}
|
|
status_sources = [
|
|
event,
|
|
event.get("user") if isinstance(event.get("user"), dict) else {},
|
|
event.get("object") if isinstance(event.get("object"), dict) else {},
|
|
event.get("employee") if isinstance(event.get("employee"), dict) else {},
|
|
]
|
|
return any(_status_source_is_disabled(source) for source in status_sources)
|
|
|
|
|
|
def _status_source_is_disabled(source: dict) -> bool:
|
|
if not isinstance(source, dict):
|
|
return False
|
|
|
|
disabled_flags = {
|
|
"is_deleted",
|
|
"is_delete",
|
|
"is_disabled",
|
|
"is_frozen",
|
|
"is_resigned",
|
|
"is_inactive",
|
|
"deleted",
|
|
"disabled",
|
|
"frozen",
|
|
"resigned",
|
|
}
|
|
for key in disabled_flags:
|
|
if source.get(key) is True:
|
|
return True
|
|
|
|
status = source.get("status")
|
|
if isinstance(status, str):
|
|
return status.lower() in {"deleted", "disabled", "disable", "frozen", "inactive", "resigned"}
|
|
if isinstance(status, dict):
|
|
for key in disabled_flags:
|
|
if status.get(key) is True:
|
|
return True
|
|
text_values = [value.lower() for value in status.values() if isinstance(value, str)]
|
|
return any(value in {"deleted", "disabled", "disable", "frozen", "inactive", "resigned"} for value in text_values)
|
|
return False
|
|
|
|
|
|
def _build_feishu_event_audit_detail(body: dict, event_type: str, open_id: str) -> dict:
|
|
header = body.get("header") if isinstance(body.get("header"), dict) else {}
|
|
return {
|
|
"event_type": event_type,
|
|
"event_id": header.get("event_id") or body.get("uuid") or "",
|
|
"open_id": open_id,
|
|
"create_time": header.get("create_time") or body.get("ts") or "",
|
|
}
|
|
|
|
|
|
@csrf_exempt
|
|
@require_POST
|
|
def exchange(request: HttpRequest) -> JsonResponse:
|
|
body = json.loads(request.body or b"{}")
|
|
code = body.get("code", "")
|
|
login_code = LoginCode.objects.select_related("user").filter(code=code).first()
|
|
if login_code is None or not login_code.is_valid:
|
|
return JsonResponse({"error": "invalid_login_code"}, status=401)
|
|
if login_code.user.status != login_code.user.STATUS_ACTIVE:
|
|
return JsonResponse({"error": "user_disabled"}, status=403)
|
|
login_code.used_at = timezone.now()
|
|
login_code.save(update_fields=["used_at"])
|
|
return JsonResponse(
|
|
{
|
|
"token": issue_jwt(login_code.user.id),
|
|
"expires_in": settings.JWT_EXPIRES_SECONDS,
|
|
"user": {
|
|
"id": login_code.user.id,
|
|
"name": login_code.user.name,
|
|
"email": login_code.user.email,
|
|
},
|
|
}
|
|
)
|