Files
abyssVpn/services/api/accounts/views.py
T
arthur_chen f69efb67ad Initial commit: abyssVpn project
- Tauri desktop app (apps/desktop)
- Python Django API service (services/api)
- Deployment scripts and docs
2026-05-15 09:06:10 +08:00

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,
},
}
)