Initial commit: abyssVpn project

- Tauri desktop app (apps/desktop)
- Python Django API service (services/api)
- Deployment scripts and docs
This commit is contained in:
arthur_chen
2026-05-15 09:06:10 +08:00
commit f69efb67ad
146 changed files with 18039 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+22
View File
@@ -0,0 +1,22 @@
from django.contrib import admin
from .models import LoginCode, OAuthState, User
@admin.register(User)
class UserAdmin(admin.ModelAdmin):
list_display = ("id", "name", "email", "feishu_open_id", "status", "created_at")
search_fields = ("name", "email", "feishu_open_id")
list_filter = ("status",)
@admin.register(OAuthState)
class OAuthStateAdmin(admin.ModelAdmin):
list_display = ("id", "state", "used_at", "expires_at", "created_at")
search_fields = ("state",)
@admin.register(LoginCode)
class LoginCodeAdmin(admin.ModelAdmin):
list_display = ("id", "user", "used_at", "expires_at", "created_at")
search_fields = ("code", "user__name", "user__email")
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "accounts"
@@ -0,0 +1,59 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="User",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("feishu_open_id", models.CharField(max_length=128, unique=True)),
("email", models.EmailField(blank=True, max_length=254)),
("name", models.CharField(blank=True, max_length=128)),
(
"status",
models.CharField(
choices=[("active", "Active"), ("disabled", "Disabled")],
default="active",
max_length=16,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name="OAuthState",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("state", models.CharField(max_length=128, unique=True)),
("used_at", models.DateTimeField(blank=True, null=True)),
("expires_at", models.DateTimeField()),
("created_at", models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name="LoginCode",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("code", models.CharField(max_length=128, unique=True)),
("used_at", models.DateTimeField(blank=True, null=True)),
("expires_at", models.DateTimeField()),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="login_codes",
to="accounts.user",
),
),
],
),
]
@@ -0,0 +1,29 @@
# Generated by Django 5.2.13 on 2026-04-28 03:08
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='DesktopLoginSession',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('session_id', models.CharField(max_length=128, unique=True)),
('state', models.CharField(max_length=128, unique=True)),
('status', models.CharField(choices=[('pending', 'Pending'), ('authenticated', 'Authenticated'), ('failed', 'Failed'), ('expired', 'Expired')], default='pending', max_length=24)),
('error', models.CharField(blank=True, max_length=255)),
('expires_at', models.DateTimeField()),
('authenticated_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='desktop_login_sessions', to='accounts.user')),
],
),
]
@@ -0,0 +1 @@
+73
View File
@@ -0,0 +1,73 @@
from django.db import models
from django.utils import timezone
class User(models.Model):
STATUS_ACTIVE = "active"
STATUS_DISABLED = "disabled"
STATUS_CHOICES = (
(STATUS_ACTIVE, "Active"),
(STATUS_DISABLED, "Disabled"),
)
feishu_open_id = models.CharField(max_length=128, unique=True)
email = models.EmailField(blank=True)
name = models.CharField(max_length=128, blank=True)
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_ACTIVE)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self) -> str:
return self.name or self.email or self.feishu_open_id
class OAuthState(models.Model):
state = models.CharField(max_length=128, unique=True)
used_at = models.DateTimeField(null=True, blank=True)
expires_at = models.DateTimeField()
created_at = models.DateTimeField(auto_now_add=True)
@property
def is_valid(self) -> bool:
return self.used_at is None and self.expires_at > timezone.now()
class LoginCode(models.Model):
code = models.CharField(max_length=128, unique=True)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="login_codes")
used_at = models.DateTimeField(null=True, blank=True)
expires_at = models.DateTimeField()
created_at = models.DateTimeField(auto_now_add=True)
@property
def is_valid(self) -> bool:
return self.used_at is None and self.expires_at > timezone.now()
class DesktopLoginSession(models.Model):
STATUS_PENDING = "pending"
STATUS_AUTHENTICATED = "authenticated"
STATUS_FAILED = "failed"
STATUS_EXPIRED = "expired"
STATUS_CHOICES = (
(STATUS_PENDING, "Pending"),
(STATUS_AUTHENTICATED, "Authenticated"),
(STATUS_FAILED, "Failed"),
(STATUS_EXPIRED, "Expired"),
)
session_id = models.CharField(max_length=128, unique=True)
state = models.CharField(max_length=128, unique=True)
user = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL, related_name="desktop_login_sessions")
status = models.CharField(max_length=24, choices=STATUS_CHOICES, default=STATUS_PENDING)
error = models.CharField(max_length=255, blank=True)
expires_at = models.DateTimeField()
authenticated_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@property
def is_expired(self) -> bool:
return self.expires_at <= timezone.now()
@@ -0,0 +1 @@
@@ -0,0 +1,273 @@
import secrets
from datetime import timedelta
from urllib.parse import parse_qs, urlencode, urljoin, urlparse
import requests
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from accounts.models import DesktopLoginSession, LoginCode, OAuthState, User
AUTHORIZE_URL = "https://open.feishu.cn/open-apis/authen/v1/index"
QR_AUTHORIZE_URL = "https://passport.feishu.cn/suite/passport/oauth/authorize"
TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v2/oauth/token"
APP_ACCESS_TOKEN_URL = "https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal"
USER_ACCESS_TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v1/access_token"
USER_INFO_URL = "https://open.feishu.cn/open-apis/authen/v1/user_info"
def build_login_url() -> dict:
state = secrets.token_urlsafe(24)
OAuthState.objects.create(
state=state,
expires_at=timezone.now() + timedelta(seconds=settings.OAUTH_STATE_EXPIRES_SECONDS),
)
params = {
"app_id": settings.FEISHU_APP_ID,
"redirect_uri": settings.FEISHU_REDIRECT_URI,
"state": state,
}
return {"url": f"{AUTHORIZE_URL}?{urlencode(params)}", "state": state}
def build_qr_login_config() -> dict:
state = secrets.token_urlsafe(24)
OAuthState.objects.create(
state=state,
expires_at=timezone.now() + timedelta(seconds=settings.OAUTH_STATE_EXPIRES_SECONDS),
)
params = {
"client_id": settings.FEISHU_APP_ID,
"redirect_uri": settings.FEISHU_REDIRECT_URI,
"response_type": "code",
"state": state,
}
return {
"goto": f"{QR_AUTHORIZE_URL}?{urlencode(params)}",
"state": state,
"expires_in": settings.OAUTH_STATE_EXPIRES_SECONDS,
}
def build_qr_goto(state: str) -> str:
params = {
"client_id": settings.FEISHU_APP_ID,
"redirect_uri": settings.FEISHU_REDIRECT_URI,
"response_type": "code",
"state": state,
}
return f"{QR_AUTHORIZE_URL}?{urlencode(params)}"
def exchange_tmp_code_for_oauth_code(tmp_code: str, state: str) -> str:
authorize_url = f"{build_qr_goto(state)}&tmp_code={tmp_code}"
session = requests.Session()
url = authorize_url
last_location = ""
for _ in range(6):
response = session.get(
url,
allow_redirects=False,
headers={"User-Agent": "Mozilla/5.0 AbyssInfoVPN/0.1"},
timeout=10,
)
if response.status_code not in (301, 302, 303, 307, 308):
raise ValueError(f"feishu_tmp_code_error:{response.status_code}")
location = response.headers.get("Location", "")
if not location:
raise ValueError("missing_feishu_redirect_location")
last_location = urljoin(url, location)
parsed = urlparse(last_location)
query = parse_qs(parsed.query)
returned_state = (query.get("state") or [""])[0]
code = (query.get("code") or [""])[0]
error = (query.get("error") or query.get("error_description") or [""])[0]
if returned_state and returned_state != state:
raise ValueError("invalid_feishu_redirect_state")
if code:
return code
if error:
raise ValueError(f"feishu_oauth_error:{error}")
if last_location.startswith(settings.FEISHU_REDIRECT_URI):
break
url = last_location
raise ValueError(f"missing_feishu_oauth_code:{last_location[:180]}")
def create_desktop_login_session() -> DesktopLoginSession:
state = secrets.token_urlsafe(24)
expires_at = timezone.now() + timedelta(seconds=settings.OAUTH_STATE_EXPIRES_SECONDS)
OAuthState.objects.create(state=state, expires_at=expires_at)
return DesktopLoginSession.objects.create(
session_id=secrets.token_urlsafe(32),
state=state,
expires_at=expires_at,
)
def build_tauri_callback(login_code: str, state: str) -> str:
return f"{settings.TAURI_DEEP_LINK_SCHEME}://login-success?code={login_code}&state={state}"
def verify_state(state: str) -> None:
oauth_state = OAuthState.objects.filter(state=state).first()
if oauth_state is None or not oauth_state.is_valid:
raise ValueError("invalid_state")
oauth_state.used_at = timezone.now()
oauth_state.save(update_fields=["used_at"])
def raise_feishu_error(response: requests.Response, fallback: str) -> None:
try:
body = response.json()
except ValueError:
body = {"raw": response.text[:300]}
message = body.get("msg") or body.get("error_description") or body.get("error") or str(body)
raise ValueError(f"{fallback}:{response.status_code}:{message}")
def fetch_app_access_token() -> str:
payload = {
"app_id": settings.FEISHU_APP_ID,
"app_secret": settings.FEISHU_APP_SECRET,
}
response = requests.post(APP_ACCESS_TOKEN_URL, json=payload, timeout=10)
if not response.ok:
raise_feishu_error(response, "feishu_app_token_http_error")
body = response.json()
if body.get("code") not in (0, None):
raise ValueError(body.get("msg") or "feishu_app_token_error")
app_access_token = body.get("app_access_token") or (body.get("data") or {}).get("app_access_token")
if not app_access_token:
raise ValueError("missing_feishu_app_access_token")
return app_access_token
def exchange_code_for_access_token(code: str) -> str:
app_access_token = fetch_app_access_token()
payload = {
"grant_type": "authorization_code",
"code": code,
}
response = requests.post(
USER_ACCESS_TOKEN_URL,
json=payload,
headers={"Authorization": f"Bearer {app_access_token}"},
timeout=10,
)
if not response.ok:
raise_feishu_error(response, "feishu_user_token_http_error")
body = response.json()
if body.get("code") not in (0, None):
raise ValueError(body.get("msg") or "feishu_user_token_error")
data = body.get("data") or body
access_token = data.get("access_token") or data.get("user_access_token")
if not access_token:
raise ValueError("missing_feishu_access_token")
return access_token
def fetch_user_info(access_token: str) -> dict:
response = requests.get(
USER_INFO_URL,
headers={"Authorization": f"Bearer {access_token}"},
timeout=10,
)
response.raise_for_status()
body = response.json()
if body.get("code") not in (0, None):
raise ValueError(body.get("msg") or "feishu_user_info_error")
return body.get("data") or body
def upsert_user_from_feishu(user_info: dict) -> User:
open_id = _first_text(
user_info.get("open_id"),
_nested_value(user_info, "user", "open_id"),
_nested_value(user_info, "user_id", "open_id"),
)
if not open_id:
raise ValueError("missing_feishu_open_id")
user, _created = User.objects.update_or_create(
feishu_open_id=open_id,
defaults={
"email": _extract_feishu_email(user_info),
"name": _extract_feishu_name(user_info),
},
)
return user
def _extract_feishu_name(user_info: dict) -> str:
return _first_text(
user_info.get("name"),
user_info.get("display_name"),
user_info.get("cn_name"),
user_info.get("nickname"),
_nested_value(user_info, "user", "name"),
_nested_value(user_info, "user", "display_name"),
_nested_value(user_info, "user", "cn_name"),
_nested_value(user_info, "user", "nickname"),
user_info.get("en_name"),
_nested_value(user_info, "user", "en_name"),
)
def _extract_feishu_email(user_info: dict) -> str:
return _first_text(user_info.get("email"), _nested_value(user_info, "user", "email"))
def _nested_value(value: dict, *keys: str):
current = value
for key in keys:
if not isinstance(current, dict):
return None
current = current.get(key)
return current
def _first_text(*values) -> str:
for value in values:
text = _text_from_value(value)
if text:
return text
return ""
def _text_from_value(value) -> str:
if isinstance(value, str):
return value.strip()
if isinstance(value, dict):
for key in ("zh_cn", "zh-CN", "zh", "default", "name", "en_us", "en-US"):
text = _text_from_value(value.get(key))
if text:
return text
return ""
def create_login_code(user: User) -> LoginCode:
return LoginCode.objects.create(
user=user,
code=secrets.token_urlsafe(32),
expires_at=timezone.now() + timedelta(seconds=settings.LOGIN_CODE_EXPIRES_SECONDS),
)
@transaction.atomic
def handle_feishu_callback(code: str, state: str) -> LoginCode:
if not code:
raise ValueError("missing_code")
verify_state(state)
access_token = exchange_code_for_access_token(code)
user_info = fetch_user_info(access_token)
user = upsert_user_from_feishu(user_info)
if user.status != User.STATUS_ACTIVE:
raise PermissionError("user_disabled")
return create_login_code(user)
@@ -0,0 +1,19 @@
from datetime import datetime, timedelta, timezone
import jwt
from django.conf import settings
def issue_jwt(user_id: int) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": str(user_id),
"iat": int(now.timestamp()),
"exp": int((now + timedelta(seconds=settings.JWT_EXPIRES_SECONDS)).timestamp()),
}
return jwt.encode(payload, settings.JWT_SECRET, algorithm="HS256")
def decode_jwt(token: str) -> dict:
return jwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
+151
View File
@@ -0,0 +1,151 @@
import json
from unittest.mock import patch
from django.test import TestCase, override_settings
from accounts.models import User
from accounts.services.feishu_oauth import upsert_user_from_feishu
from vpn.models import Device, VpnLog
class FeishuUserInfoTests(TestCase):
def test_upsert_user_prefers_chinese_display_name(self):
user = upsert_user_from_feishu(
{
"open_id": "ou_name",
"email": "chenhuan@example.com",
"name": {"zh_cn": "陈欢", "en_us": "Chen Huan"},
"en_name": "Chen Huan",
}
)
self.assertEqual(user.name, "陈欢")
self.assertEqual(user.email, "chenhuan@example.com")
def test_upsert_user_reads_nested_user_payload(self):
user = upsert_user_from_feishu(
{
"user": {
"open_id": "ou_nested",
"display_name": "陈欢",
"email": "chenhuan@example.com",
}
}
)
self.assertEqual(user.feishu_open_id, "ou_nested")
self.assertEqual(user.name, "陈欢")
@override_settings(FEISHU_EVENT_VERIFICATION_TOKEN="verify-token")
class FeishuEventCallbackTests(TestCase):
def setUp(self):
self.user = User.objects.create(feishu_open_id="ou_123", email="u@example.com", name="User")
self.device = Device.objects.create(
user=self.user,
device_name="pc-01",
device_fingerprint="fingerprint-01",
netmaker_node_id="host-01",
)
def test_url_verification_returns_challenge(self):
response = self.client.post(
"/auth/feishu/event-callback",
data=json.dumps({"token": "verify-token", "challenge": "challenge-value"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["challenge"], "challenge-value")
@override_settings(FEISHU_EVENT_VERIFICATION_TOKEN="", DEBUG=False)
def test_url_verification_can_pass_before_token_is_configured(self):
response = self.client.post(
"/auth/feishu/event-callback",
data=json.dumps({"challenge": "challenge-value"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["challenge"], "challenge-value")
@override_settings(FEISHU_EVENT_VERIFICATION_TOKEN="", DEBUG=False)
def test_real_events_require_token_in_production(self):
response = self.client.post(
"/auth/feishu/event-callback",
data=json.dumps(
{
"schema": "2.0",
"header": {"event_type": "contact.user.deleted_v3"},
"event": {"object": {"open_id": "ou_123"}},
}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
@patch("vpn.services.offboarding.NetmakerClient")
def test_deleted_user_event_disables_user_and_revokes_vpn(self, client_cls):
response = self.client.post(
"/auth/feishu/event-callback",
data=json.dumps(
{
"schema": "2.0",
"header": {
"event_type": "contact.user.deleted_v3",
"event_id": "event-01",
"token": "verify-token",
},
"event": {"object": {"open_id": "ou_123"}},
}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.user.refresh_from_db()
self.device.refresh_from_db()
self.assertEqual(self.user.status, User.STATUS_DISABLED)
self.assertEqual(self.device.status, Device.STATUS_REVOKED)
client_cls.return_value.delete_node.assert_called_once_with("host-01")
self.assertTrue(VpnLog.objects.filter(user=self.user, action="user_disabled").exists())
@patch("vpn.services.offboarding.NetmakerClient")
def test_frozen_user_update_disables_user_and_revokes_vpn(self, client_cls):
response = self.client.post(
"/auth/feishu/event-callback",
data=json.dumps(
{
"schema": "2.0",
"header": {
"event_type": "contact.user.updated_v3",
"event_id": "event-02",
"token": "verify-token",
},
"event": {
"object": {
"open_id": "ou_123",
"status": {"is_frozen": True},
}
},
}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.user.refresh_from_db()
self.device.refresh_from_db()
self.assertEqual(self.user.status, User.STATUS_DISABLED)
self.assertEqual(self.device.status, Device.STATUS_REVOKED)
client_cls.return_value.delete_node.assert_called_once_with("host-01")
def test_invalid_event_token_is_rejected(self):
response = self.client.post(
"/auth/feishu/event-callback",
data=json.dumps({"token": "wrong", "challenge": "challenge-value"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 403)
+3
View File
@@ -0,0 +1,3 @@
from .services.jwt_service import decode_jwt, issue_jwt
__all__ = ["decode_jwt", "issue_jwt"]
+17
View File
@@ -0,0 +1,17 @@
from django.urls import path
from . import views
urlpatterns = [
path("feishu/login-url", views.feishu_login_url),
path("feishu/qr-config", views.feishu_qr_config),
path("feishu/qr-page", views.feishu_qr_page),
path("feishu/callback", views.feishu_callback),
path("feishu/event-callback", views.feishu_event_callback),
path("desktop-session", views.desktop_session_create),
path("desktop-session/<str:session_id>", views.desktop_session_status),
path("desktop-session/<str:session_id>/complete", views.desktop_session_complete),
path("desktop-session/<str:session_id>/qr-page", views.desktop_session_qr_page),
path("exchange", views.exchange),
]
+589
View File
@@ -0,0 +1,589 @@
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,
},
}
)