Initial commit: abyssVpn project
- Tauri desktop app (apps/desktop) - Python Django API service (services/api) - Deployment scripts and docs
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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")
|
||||
@@ -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 @@
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
from .services.jwt_service import decode_jwt, issue_jwt
|
||||
|
||||
__all__ = ["decode_jwt", "issue_jwt"]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user