TelegramAuth + TelegramAuthBot + WatchTogether; README/AGENT
- mods/TelegramAuth, mods/TelegramAuthBot, mods/WatchTogether: копии из образа с enable: true (правило 1), секции в config/init.conf - TelegramAuth в API-режиме (enable: false -> accsdb/гейт не поднимается), TelegramAuthBot: бот @GatusMonBot, long polling - WatchTogether: customPlugins watchtogether.js, relay /wt/c/ - README: секция про SQLite/volume (первый запуск), таблица модулей, состояние Tg-notify и TelegramAuth, раздел WatchTogether - AGENT: правило 10 дополнено секретами init.conf, §12 переписан, добавлен §13 WatchTogether, список mods/ в §1 - в git bot_token и mutations_api_secret оставлены пустыми (правило 10), реальные значения только в рабочем файле на сервере
This commit is contained in:
@@ -0,0 +1,519 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using Shared;
|
||||
using Shared.Attributes;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using TelegramAuth.Models;
|
||||
using TelegramAuth.Services;
|
||||
|
||||
namespace TelegramAuth.Controllers
|
||||
{
|
||||
[Authorization("access denied")]
|
||||
public class TelegramAuthController : BaseController
|
||||
{
|
||||
public const string MutationsSecretHeaderName = "X-TelegramAuth-Mutations-Secret";
|
||||
|
||||
static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver(),
|
||||
NullValueHandling = NullValueHandling.Ignore
|
||||
};
|
||||
|
||||
static TelegramAuthStore store => ModInit.Store;
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/status")]
|
||||
public ActionResult Status([FromQuery] string uid)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(uid))
|
||||
return JsonError(400, "uid is required");
|
||||
|
||||
var status = store.GetStatus(uid);
|
||||
return JsonOk(status);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/me")]
|
||||
public ActionResult Me([FromQuery] string uid)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(uid))
|
||||
return JsonError(400, "uid is required");
|
||||
|
||||
var user = store.FindByUid(uid);
|
||||
if (user == null)
|
||||
return JsonError(404, "user not found for uid");
|
||||
|
||||
return JsonOk(user);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/bind/start")]
|
||||
public ActionResult BindStart([FromBody] BindStartRequest? request)
|
||||
{
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.Uid))
|
||||
return JsonError(400, "uid is required");
|
||||
|
||||
var response = new
|
||||
{
|
||||
ok = true,
|
||||
pending = true,
|
||||
uid = request.Uid,
|
||||
message = "Привяжи UID в Telegram-боте."
|
||||
};
|
||||
|
||||
return JsonOk(response);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/bind/complete")]
|
||||
public ActionResult BindComplete([FromBody] BindCompleteRequest? request)
|
||||
{
|
||||
if (HasConfiguredMutationsSecret() && !HasMutationAccess())
|
||||
return MutationUnauthorized();
|
||||
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.Uid) || string.IsNullOrWhiteSpace(request.TelegramId))
|
||||
return JsonError(400, "uid and telegramId are required");
|
||||
|
||||
try
|
||||
{
|
||||
var bindOutcome = store.BindDevice(request.TelegramId, request.Uid, request.Username, request.DeviceName, "manual-complete");
|
||||
return JsonOk(new
|
||||
{
|
||||
ok = true,
|
||||
message = bindOutcome.PendingAdminApproval
|
||||
? "Устройство привязано; доступ включит администратор"
|
||||
: "Устройство привязано",
|
||||
uid = request.Uid,
|
||||
telegramId = request.TelegramId,
|
||||
newUserProvisioned = bindOutcome.NewUserProvisioned,
|
||||
pendingAdminApproval = bindOutcome.PendingAdminApproval
|
||||
});
|
||||
}
|
||||
catch (TelegramAuthBindException ex) when (ex.FailureKind == TelegramAuthBindFailureKind.UserNotFound)
|
||||
{
|
||||
return JsonError(404, "user not found", "Включите auto_provision_users в TelegramAuth или добавьте пользователя в users.json.");
|
||||
}
|
||||
catch (TelegramAuthBindException ex) when (ex.FailureKind == TelegramAuthBindFailureKind.UserDisabled)
|
||||
{
|
||||
return JsonError(403, "user disabled", "Аккаунт отключён администратором.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonError(500, "bind failed", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/user/by-telegram")]
|
||||
public ActionResult UserByTelegram([FromQuery] string telegramId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(telegramId))
|
||||
return JsonError(400, "telegramId is required");
|
||||
|
||||
var user = store.FindByTelegramId(telegramId);
|
||||
if (user == null)
|
||||
{
|
||||
return JsonOk(new
|
||||
{
|
||||
found = false,
|
||||
telegramId
|
||||
});
|
||||
}
|
||||
|
||||
var registrationPending = TelegramAuthStore.IsRegistrationPending(user);
|
||||
return JsonOk(new
|
||||
{
|
||||
found = true,
|
||||
telegramId = user.TelegramId,
|
||||
username = user.TgUsername,
|
||||
role = user.Role,
|
||||
lang = user.Lang,
|
||||
approvedBy = user.ApprovedBy,
|
||||
createdAt = user.CreatedAt,
|
||||
expiresAt = user.ExpiresAt,
|
||||
disabled = user.Disabled,
|
||||
registrationPending,
|
||||
active = store.IsActive(user),
|
||||
deviceCount = user.Devices.Count(d => d.Active),
|
||||
maxDevices = store.GetMaxDevices(user)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/admin/users")]
|
||||
public ActionResult AdminListUsers()
|
||||
{
|
||||
if (!HasMutationAccess())
|
||||
return MutationUnauthorized();
|
||||
|
||||
var users = store.GetUsers()
|
||||
.OrderBy(u => u.TelegramId, StringComparer.Ordinal)
|
||||
.Select(u => new
|
||||
{
|
||||
telegramId = u.TelegramId,
|
||||
username = u.TgUsername,
|
||||
role = u.Role,
|
||||
disabled = u.Disabled,
|
||||
registrationPending = TelegramAuthStore.IsRegistrationPending(u),
|
||||
active = store.IsActive(u),
|
||||
expiresAt = u.ExpiresAt,
|
||||
deviceCount = u.Devices.Count(d => d.Active),
|
||||
accs = u.Accs == null
|
||||
? null
|
||||
: new
|
||||
{
|
||||
u.Accs.group,
|
||||
u.Accs.IsPasswd,
|
||||
u.Accs.ban,
|
||||
u.Accs.ban_msg,
|
||||
u.Accs.comment,
|
||||
u.Accs.ids,
|
||||
u.Accs.@params
|
||||
}
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return JsonOk(new { ok = true, users });
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/admin/user")]
|
||||
public ActionResult AdminGetUser([FromQuery] string telegramId)
|
||||
{
|
||||
if (!HasMutationAccess())
|
||||
return MutationUnauthorized();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(telegramId))
|
||||
return JsonError(400, "telegramId is required");
|
||||
|
||||
var user = store.FindByTelegramId(telegramId.Trim());
|
||||
if (user == null)
|
||||
return JsonError(404, "user not found");
|
||||
|
||||
return JsonOk(new
|
||||
{
|
||||
ok = true,
|
||||
telegramId = user.TelegramId,
|
||||
username = user.TgUsername,
|
||||
role = user.Role,
|
||||
lang = user.Lang,
|
||||
approvedBy = user.ApprovedBy,
|
||||
createdAt = user.CreatedAt,
|
||||
expiresAt = user.ExpiresAt,
|
||||
disabled = user.Disabled,
|
||||
registrationPending = TelegramAuthStore.IsRegistrationPending(user),
|
||||
active = store.IsActive(user),
|
||||
deviceCount = user.Devices.Count(d => d.Active),
|
||||
maxDevices = store.GetMaxDevices(user),
|
||||
accs = user.Accs,
|
||||
devices = user.Devices.Select(d => new
|
||||
{
|
||||
uid = d.Uid,
|
||||
name = d.Name,
|
||||
active = d.Active,
|
||||
linkedAt = d.LinkedAt,
|
||||
lastSeenAt = d.LastSeenAt,
|
||||
source = d.Source
|
||||
}).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/admin/user/patch")]
|
||||
public async Task<ActionResult> AdminPatchUser()
|
||||
{
|
||||
if (!HasMutationAccess())
|
||||
return MutationUnauthorized();
|
||||
|
||||
string raw;
|
||||
using (var reader = new StreamReader(Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true))
|
||||
raw = await reader.ReadToEndAsync().ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return JsonError(400, "body is required");
|
||||
|
||||
JObject body;
|
||||
try
|
||||
{
|
||||
body = JObject.Parse(raw);
|
||||
}
|
||||
catch (JsonReaderException)
|
||||
{
|
||||
return JsonError(400, "invalid json");
|
||||
}
|
||||
|
||||
var tid = body.Value<string>("telegramId");
|
||||
if (string.IsNullOrWhiteSpace(tid))
|
||||
return JsonError(400, "telegramId is required");
|
||||
|
||||
var outcome = store.TryAdminPatchUser(tid.Trim(), body, out var err);
|
||||
if (outcome == TelegramAuthStore.AdminPatchUserOutcome.NotFound)
|
||||
return JsonError(404, "user not found");
|
||||
if (outcome == TelegramAuthStore.AdminPatchUserOutcome.InvalidPayload)
|
||||
return JsonError(400, "invalid payload", err);
|
||||
|
||||
return JsonOk(new { ok = true, telegramId = tid.Trim() });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/admin/user/disabled")]
|
||||
public ActionResult AdminSetUserDisabled([FromBody] AdminSetUserDisabledRequest? request)
|
||||
{
|
||||
if (!HasMutationAccess())
|
||||
return MutationUnauthorized();
|
||||
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.TelegramId))
|
||||
return JsonError(400, "telegramId is required");
|
||||
|
||||
var outcome = store.TrySetUserDisabled(request.TelegramId.Trim(), request.Disabled);
|
||||
if (outcome == TelegramAuthStore.SetUserDisabledOutcome.NotFound)
|
||||
return JsonError(404, "user not found");
|
||||
if (outcome == TelegramAuthStore.SetUserDisabledOutcome.CannotDisableAdmin)
|
||||
return JsonError(403, "cannot disable admin", "Нельзя отключить учётную запись с ролью admin.");
|
||||
|
||||
return JsonOk(new
|
||||
{
|
||||
ok = true,
|
||||
telegramId = request.TelegramId.Trim(),
|
||||
disabled = request.Disabled
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/admin/user/pending")]
|
||||
public ActionResult AdminResolveRegistrationPending([FromBody] AdminPendingDecisionRequest? request)
|
||||
{
|
||||
if (!HasMutationAccess())
|
||||
return MutationUnauthorized();
|
||||
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.TelegramId))
|
||||
return JsonError(400, "telegramId is required");
|
||||
|
||||
var tid = request.TelegramId.Trim();
|
||||
if (request.Approve)
|
||||
{
|
||||
var outcome = store.TryApproveRegistrationPending(tid);
|
||||
if (outcome == TelegramAuthStore.PendingDecisionOutcome.NotFound)
|
||||
return JsonError(404, "user not found");
|
||||
if (outcome == TelegramAuthStore.PendingDecisionOutcome.NotPending)
|
||||
return JsonError(400, "not pending", "Пользователь не в статусе ожидания подтверждения.");
|
||||
|
||||
return JsonOk(new { ok = true, telegramId = tid, approved = true });
|
||||
}
|
||||
else
|
||||
{
|
||||
var outcome = store.TryRejectRegistrationPending(tid);
|
||||
if (outcome == TelegramAuthStore.PendingDecisionOutcome.NotFound)
|
||||
return JsonError(404, "user not found");
|
||||
if (outcome == TelegramAuthStore.PendingDecisionOutcome.NotPending)
|
||||
return JsonError(400, "not pending", "Пользователь не в статусе ожидания подтверждения.");
|
||||
if (outcome == TelegramAuthStore.PendingDecisionOutcome.CannotRejectAdmin)
|
||||
return JsonError(403, "cannot reject admin");
|
||||
|
||||
return JsonOk(new { ok = true, telegramId = tid, rejected = true });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/devices")]
|
||||
public ActionResult Devices([FromQuery] string telegramId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(telegramId))
|
||||
return JsonError(400, "telegramId is required");
|
||||
|
||||
var user = store.FindByTelegramId(telegramId);
|
||||
if (user == null)
|
||||
return JsonError(404, "user not found");
|
||||
|
||||
return JsonOk(new
|
||||
{
|
||||
telegramId = user.TelegramId,
|
||||
username = user.TgUsername,
|
||||
devices = user.Devices
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/device/unbind")]
|
||||
public ActionResult Unbind([FromBody] DeviceUnbindRequest? request)
|
||||
{
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.Uid) || string.IsNullOrWhiteSpace(request.TelegramId))
|
||||
return JsonError(400, "telegramId and uid are required");
|
||||
|
||||
var outcome = store.TryUnbindDevice(request.TelegramId.Trim(), request.Uid.Trim());
|
||||
if (outcome == TelegramAuthStore.UnbindDeviceOutcome.UserNotFound)
|
||||
return JsonError(404, "user not found");
|
||||
if (outcome == TelegramAuthStore.UnbindDeviceOutcome.DeviceNotFound)
|
||||
return JsonError(404, "device not found", "UID не найден среди устройств этого пользователя.");
|
||||
|
||||
return JsonOk(new { ok = true, uid = request.Uid.Trim() });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/device/reactivate")]
|
||||
public ActionResult ReactivateDevice([FromBody] DeviceReactivateRequest? request)
|
||||
{
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.TelegramId) || string.IsNullOrWhiteSpace(request.Uid))
|
||||
return JsonError(400, "telegramId and uid are required");
|
||||
|
||||
var outcome = store.TryReactivateDevice(request.TelegramId.Trim(), request.Uid.Trim());
|
||||
if (outcome == TelegramAuthStore.ReactivateDeviceOutcome.UserNotFound)
|
||||
return JsonError(404, "user not found");
|
||||
if (outcome == TelegramAuthStore.ReactivateDeviceOutcome.UserDisabled)
|
||||
return JsonError(403, "user disabled", "Аккаунт отключён администратором.");
|
||||
if (outcome == TelegramAuthStore.ReactivateDeviceOutcome.DeviceNotFound)
|
||||
return JsonError(404, "device not found", "UID не найден среди ваших устройств.");
|
||||
|
||||
return JsonOk(new { ok = true, uid = request.Uid.Trim() });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/device/name")]
|
||||
public ActionResult SetDeviceName([FromBody] DeviceSetNameRequest? request)
|
||||
{
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.Uid))
|
||||
return JsonError(400, "uid is required");
|
||||
|
||||
var outcome = store.TrySetActiveDeviceDisplayName(request.Uid, request.Name);
|
||||
if (outcome == TelegramAuthStore.SetDeviceDisplayNameOutcome.InvalidUid)
|
||||
return JsonError(400, "uid is required");
|
||||
if (outcome == TelegramAuthStore.SetDeviceDisplayNameOutcome.NotFoundOrInactive)
|
||||
return JsonError(404, "device not found or access inactive", "UID не привязан или срок доступа истёк.");
|
||||
|
||||
return JsonOk(new
|
||||
{
|
||||
ok = true,
|
||||
uid = request.Uid.Trim(),
|
||||
name = string.IsNullOrWhiteSpace(request.Name) ? (string?)null : request.Name.Trim()
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/devices/cleanup")]
|
||||
public ActionResult CleanupDevices()
|
||||
{
|
||||
if (!HasMutationAccess())
|
||||
return MutationUnauthorized();
|
||||
|
||||
if (!ModInit.conf.enable_cleanup)
|
||||
return JsonError(404, "cleanup disabled");
|
||||
|
||||
try
|
||||
{
|
||||
var removed = store.CleanupInactiveDevices();
|
||||
return JsonOk(new { ok = true, removed });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonError(500, "cleanup failed", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[Route("/tg/auth/import")]
|
||||
public ActionResult ImportLegacy()
|
||||
{
|
||||
if (!HasMutationAccess())
|
||||
return MutationUnauthorized();
|
||||
|
||||
if (!ModInit.conf.enable_import)
|
||||
return JsonError(404, "import disabled");
|
||||
|
||||
var legacyPath = ModInit.conf.legacy_import_path?.Trim();
|
||||
if (string.IsNullOrEmpty(legacyPath))
|
||||
return JsonError(400, "legacy_import_path is not configured");
|
||||
|
||||
try
|
||||
{
|
||||
var result = store.ImportFromLegacy(legacyPath);
|
||||
return JsonOk(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return JsonError(500, "import failed", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
bool HasConfiguredMutationsSecret() =>
|
||||
!string.IsNullOrEmpty(ModInit.conf.mutations_api_secret?.Trim());
|
||||
|
||||
bool HasMutationAccess()
|
||||
{
|
||||
if (Request.Cookies.TryGetValue("accspasswd", out var passwd)
|
||||
&& SecretEquals(passwd, CoreInit.rootPasswd))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var expected = ModInit.conf.mutations_api_secret?.Trim();
|
||||
if (string.IsNullOrEmpty(expected)
|
||||
|| !Request.Headers.TryGetValue(MutationsSecretHeaderName, out var supplied))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return SecretEquals(supplied.ToString().Trim(), expected);
|
||||
}
|
||||
|
||||
static bool SecretEquals(string? supplied, string? expected)
|
||||
{
|
||||
if (string.IsNullOrEmpty(supplied) || string.IsNullOrEmpty(expected))
|
||||
return false;
|
||||
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(supplied),
|
||||
Encoding.UTF8.GetBytes(expected));
|
||||
}
|
||||
|
||||
ContentResult MutationUnauthorized() => JsonError(401, "unauthorized");
|
||||
|
||||
ContentResult JsonOk(object data)
|
||||
{
|
||||
return new ContentResult
|
||||
{
|
||||
Content = JsonConvert.SerializeObject(data, JsonSettings),
|
||||
ContentType = "application/json; charset=utf-8",
|
||||
StatusCode = 200
|
||||
};
|
||||
}
|
||||
|
||||
ContentResult JsonError(int status, string error, string? detail = null)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
error,
|
||||
detail
|
||||
};
|
||||
return new ContentResult
|
||||
{
|
||||
Content = JsonConvert.SerializeObject(payload, JsonSettings),
|
||||
ContentType = "application/json; charset=utf-8",
|
||||
StatusCode = status
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Shared;
|
||||
using Shared.Models.AppConf;
|
||||
using Shared.Models.Events;
|
||||
using Shared.Models.Module;
|
||||
using Shared.Models.Module.Interfaces;
|
||||
using Shared.Services;
|
||||
using TelegramAuth.Models;
|
||||
using TelegramAuth.Services;
|
||||
|
||||
namespace TelegramAuth
|
||||
{
|
||||
public class ModInit : IModuleLoaded
|
||||
{
|
||||
public static TelegramAuthConf conf = new();
|
||||
|
||||
public static TelegramAuthStore Store { get; private set; } = null!;
|
||||
|
||||
public void Loaded(InitspaceModel initspace)
|
||||
{
|
||||
UpdateConfFromInit();
|
||||
EventListener.UpdateInitFile += UpdateConfFromInit;
|
||||
}
|
||||
|
||||
void UpdateConfFromInit()
|
||||
{
|
||||
conf = ModuleInvoke.Init("TelegramAuth", new TelegramAuthConf
|
||||
{
|
||||
enable = false,
|
||||
data_dir = null,
|
||||
legacy_import_path = "",
|
||||
enable_import = true,
|
||||
enable_cleanup = true,
|
||||
max_active_devices_per_user = 0,
|
||||
mutations_api_secret = "",
|
||||
owner_telegram_ids = null,
|
||||
auto_provision_users = false,
|
||||
auto_provision_role = "user",
|
||||
auto_provision_lang = "ru",
|
||||
auto_provision_expires_days = 0,
|
||||
auto_provision_activate_immediately = false,
|
||||
accsdb_sync_group_admin = 100,
|
||||
accsdb_sync_group_user = 0,
|
||||
limit_map = new List<WafLimitRootMap>
|
||||
{
|
||||
new("^/tg/auth", new WafLimitMap { limit = 5, second = 1 })
|
||||
}
|
||||
});
|
||||
|
||||
if (CoreInit.conf != null && conf.enable)
|
||||
CoreInit.conf.accsdb.enable = true;
|
||||
|
||||
Store = new TelegramAuthStore(conf);
|
||||
Store.EnsureStorage();
|
||||
Store.EnsureOwnerUsersAtStartup();
|
||||
|
||||
if (conf.enable)
|
||||
ApplyWafLimitMapFromConf();
|
||||
}
|
||||
|
||||
void ApplyWafLimitMapFromConf()
|
||||
{
|
||||
var waf = CoreInit.conf?.WAF?.limit_map;
|
||||
if (waf == null)
|
||||
return;
|
||||
|
||||
var ours = conf.limit_map ?? new List<WafLimitRootMap>();
|
||||
var patterns = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var m in ours)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(m?.pattern))
|
||||
patterns.Add(m.pattern);
|
||||
}
|
||||
|
||||
if (patterns.Count == 0)
|
||||
return;
|
||||
|
||||
waf.RemoveAll(x => x != null && !string.IsNullOrEmpty(x.pattern) && patterns.Contains(x.pattern));
|
||||
foreach (var m in ours)
|
||||
waf.Insert(0, m);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
EventListener.UpdateInitFile -= UpdateConfFromInit;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Shared.Models.AppConf;
|
||||
using Shared.Models.Module;
|
||||
|
||||
namespace TelegramAuth.Models
|
||||
{
|
||||
public class TelegramAuthConf : ModuleBaseConf
|
||||
{
|
||||
public bool enable { get; set; }
|
||||
|
||||
public string? data_dir { get; set; }
|
||||
|
||||
public string legacy_import_path { get; set; } = "";
|
||||
|
||||
public bool enable_import { get; set; } = true;
|
||||
|
||||
public bool enable_cleanup { get; set; } = true;
|
||||
|
||||
public int max_active_devices_per_user { get; set; }
|
||||
|
||||
public string mutations_api_secret { get; set; } = "";
|
||||
|
||||
public long[]? owner_telegram_ids { get; set; }
|
||||
|
||||
public bool auto_provision_users { get; set; }
|
||||
|
||||
public string auto_provision_role { get; set; } = "user";
|
||||
|
||||
public string auto_provision_lang { get; set; } = "ru";
|
||||
|
||||
public int auto_provision_expires_days { get; set; }
|
||||
|
||||
public bool auto_provision_activate_immediately { get; set; }
|
||||
|
||||
public int accsdb_sync_group_admin { get; set; } = 100;
|
||||
|
||||
public int accsdb_sync_group_user { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace TelegramAuth.Models
|
||||
{
|
||||
public class LegacyDeviceRecord
|
||||
{
|
||||
public string uid { get; set; } = "";
|
||||
public string? label { get; set; }
|
||||
public string? bound_at { get; set; }
|
||||
public string? last_seen { get; set; }
|
||||
public string? last_ip { get; set; }
|
||||
public string? fingerprint { get; set; }
|
||||
}
|
||||
|
||||
public class LegacyTokenRecord
|
||||
{
|
||||
public string token { get; set; } = "";
|
||||
public string telegram_id { get; set; } = "";
|
||||
public string tg_username { get; set; } = "";
|
||||
public DateTime? created_at { get; set; }
|
||||
public DateTime? expires_at { get; set; }
|
||||
public string approved_by { get; set; } = "";
|
||||
public List<LegacyDeviceRecord> devices { get; set; } = new();
|
||||
}
|
||||
|
||||
public class DeviceRecord
|
||||
{
|
||||
public string Uid { get; set; } = "";
|
||||
public string? Name { get; set; }
|
||||
public DateTime LinkedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? LastSeenAt { get; set; }
|
||||
public bool Active { get; set; } = true;
|
||||
public string Source { get; set; } = "import";
|
||||
}
|
||||
|
||||
public class TelegramAccsProfile
|
||||
{
|
||||
[JsonProperty("group")]
|
||||
public int? group { get; set; }
|
||||
|
||||
[JsonProperty("IsPasswd")]
|
||||
public bool? IsPasswd { get; set; }
|
||||
|
||||
public bool? ban { get; set; }
|
||||
|
||||
public string? ban_msg { get; set; }
|
||||
|
||||
public string? comment { get; set; }
|
||||
|
||||
[JsonProperty("params")]
|
||||
public Dictionary<string, object>? @params { get; set; }
|
||||
|
||||
public List<string>? ids { get; set; }
|
||||
}
|
||||
|
||||
public class TelegramUserRecord
|
||||
{
|
||||
public string TelegramId { get; set; } = "";
|
||||
public string TgUsername { get; set; } = "";
|
||||
public string Role { get; set; } = "user";
|
||||
public string Lang { get; set; } = "ru";
|
||||
public string? ApprovedBy { get; set; }
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
|
||||
|
||||
public bool Disabled { get; set; }
|
||||
|
||||
public bool RegistrationPending { get; set; }
|
||||
|
||||
public List<DeviceRecord> Devices { get; set; } = new();
|
||||
|
||||
[JsonProperty("accs")]
|
||||
public TelegramAccsProfile? Accs { get; set; }
|
||||
}
|
||||
|
||||
public class AuthStatusResponse
|
||||
{
|
||||
public bool Authorized { get; set; }
|
||||
public bool Pending { get; set; }
|
||||
public bool RegistrationPending { get; set; }
|
||||
public string? Message { get; set; }
|
||||
public string? TelegramId { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string? Role { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public int DeviceCount { get; set; }
|
||||
}
|
||||
|
||||
public class BindStartRequest
|
||||
{
|
||||
public string Uid { get; set; } = "";
|
||||
}
|
||||
|
||||
public class DeviceSetNameRequest
|
||||
{
|
||||
[JsonProperty("uid", Required = Required.Always)]
|
||||
public string Uid { get; set; } = "";
|
||||
[JsonProperty("name")]
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
|
||||
public class DeviceReactivateRequest
|
||||
{
|
||||
public string TelegramId { get; set; } = "";
|
||||
public string Uid { get; set; } = "";
|
||||
}
|
||||
|
||||
public class DeviceUnbindRequest
|
||||
{
|
||||
public string TelegramId { get; set; } = "";
|
||||
public string Uid { get; set; } = "";
|
||||
}
|
||||
|
||||
public class BindCompleteRequest
|
||||
{
|
||||
public string Uid { get; set; } = "";
|
||||
public string TelegramId { get; set; } = "";
|
||||
public string? Username { get; set; }
|
||||
public string? DeviceName { get; set; }
|
||||
}
|
||||
|
||||
public class ImportResult
|
||||
{
|
||||
public int ImportedUsers { get; set; }
|
||||
public int ImportedDevices { get; set; }
|
||||
public int ImportedAdmins { get; set; }
|
||||
public int ImportedLangs { get; set; }
|
||||
}
|
||||
|
||||
public class AdminSetUserDisabledRequest
|
||||
{
|
||||
public string TelegramId { get; set; } = "";
|
||||
public bool Disabled { get; set; } = true;
|
||||
}
|
||||
|
||||
public class AdminPendingDecisionRequest
|
||||
{
|
||||
public string TelegramId { get; set; } = "";
|
||||
[JsonProperty("approve")]
|
||||
public bool Approve { get; set; } = true;
|
||||
}
|
||||
|
||||
public class BindDeviceOutcome
|
||||
{
|
||||
public bool NewUserProvisioned { get; set; }
|
||||
public bool PendingAdminApproval { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
# TelegramAuth
|
||||
|
||||
HTTP API и файловое хранилище для привязки **UID устройства Lampa** к учётке Telegram. Проект: **`Modules/Community/TelegramAuth/`**. Работает с [TelegramAuthBot](../TelegramAuthBot/README.md) и с клиентским плагином (см. [обзор Community](../README.md) — `deny.js` / `telegram_auth_gate.js`).
|
||||
|
||||
---
|
||||
|
||||
## Включение
|
||||
|
||||
- Убедитесь, что **`TelegramAuth`** (и при использовании бота — **`TelegramAuthBot`**) **нет** в **`BaseModule.SkipModules`** в `init.conf` (в шаблоне [`config/base.conf`](../../../config/base.conf) оба отключены по умолчанию).
|
||||
- [`manifest.json`](manifest.json): **`"enable": true`**.
|
||||
- `init.conf`: секция **`TelegramAuth`**. Пример: [`init.merge.example.json`](init.merge.example.json).
|
||||
|
||||
---
|
||||
|
||||
## Назначение
|
||||
|
||||
- Пользователи Telegram: `telegramId`, роль, срок доступа, устройства (UID).
|
||||
- Статус по UID для UI: привязан / ожидание / истёк срок.
|
||||
- Лимит **активных** устройств для роли `user` (`max_active_devices_per_user`; у `admin` — без лимита).
|
||||
- Опционально: импорт legacy, очистка устройств.
|
||||
|
||||
---
|
||||
|
||||
## Связь с accsdb
|
||||
|
||||
Если **`TelegramAuth.enable`** = **`true`**:
|
||||
|
||||
- В Core выставляется **`accsdb.enable`**.
|
||||
- Активные привязанные UID синхронизируются в **корневой** **`users.json`** (рядом с `init.conf`) в формате **`AccsUser`**. Хост обычно подхватывает файл в течение ~1 с.
|
||||
|
||||
Если **`enable`** = **`false`**: только хранилище и HTTP API, без accsdb и без подмешивания WAF из этой секции.
|
||||
|
||||
**Когда UID попадает в корневой `users.json`:** пользователь **не** в `RegistrationPending`, **не** `Disabled`, устройство **активно**. Иначе для затронутых UID выполняется **`RemoveUid`**. При нехватке слотов под новое устройство самое старое активное переводится в неактивное и его UID снимается с accsdb.
|
||||
|
||||
Поле **`accs`** в записи пользователя в **`data_dir`/users.json** задаёт группу, бан, `params` (в т.ч. `telegram_id`) и т.д.; срок в accsdb берётся из **`ExpiresAt`** или **`accsdb.shared_daytime`** / 365 дней. Подробная логика: [`Services/AccsdbUidSync.cs`](Services/AccsdbUidSync.cs).
|
||||
|
||||
**Регистрация:** при выключенном `auto_provision_users` запись с таким `telegramId` уже должна быть в **`data_dir`/users.json`. При включённом — может создаваться автоматически (см. таблицу конфига). Ожидание модерации: **`RegistrationPending`** + при создании часто **`Disabled`**. Подтверждение: `POST /tg/auth/admin/user/pending` или бот.
|
||||
|
||||
**`owner_telegram_ids`:** при старте модуля создаются/обновляются как **admin** в **`data_dir`/users.json**.
|
||||
|
||||
Ограничение **чатов** для админ-команд бота задаётся в **`TelegramAuthBot.admin_chat_ids`**, не здесь.
|
||||
|
||||
---
|
||||
|
||||
## Конфигурация
|
||||
|
||||
| Поле | Описание |
|
||||
|------|----------|
|
||||
| `enable` | `true` — accsdb + синхронизация UID + применение **`limit_map`** к WAF. `false` (по умолчанию) — только API/хранилище. |
|
||||
| `data_dir` | Каталог данных (по умолчанию `database/tgauth`). |
|
||||
| `legacy_import_path` | База для `POST /tg/auth/import`. |
|
||||
| `enable_import` / `enable_cleanup` | Разрешить импорт / `POST /tg/auth/devices/cleanup`. |
|
||||
| `max_active_devices_per_user` | Лимит активных устройств для `user`; `0` → **5**. Для `admin` не применяется. |
|
||||
| `mutations_api_secret` | Заголовок **`X-TelegramAuth-Mutations-Secret`** (совпадает с ботом). Пусто → `bind/complete` без заголовка; мутации админа из бота без cookie — невозможны. |
|
||||
| `owner_telegram_ids` | Владельцы → admin при старте. |
|
||||
| `auto_provision_users` | Создавать пользователя при bind неизвестного `telegramId`. |
|
||||
| `auto_provision_role` | Роль новой записи (`admin` через auto-provision недоступен → `user`). |
|
||||
| `auto_provision_lang` | Язык по умолчанию. |
|
||||
| `auto_provision_expires_days` | Срок в днях; `0` — без срока. |
|
||||
| `auto_provision_activate_immediately` | `false` — pending до решения админа; `true` — сразу активен. |
|
||||
| `limit_map` | Правила WAF (тот же формат, что **`WAF.limit_map`** в Core). |
|
||||
| `accsdb_sync_group_admin` / `accsdb_sync_group_user` | Значение **`group`** в accsdb, если не задано в **`accs.group`** (по умолчанию 100 / 0). |
|
||||
|
||||
### WAF и `limit_map`
|
||||
|
||||
При **`TelegramAuth.enable`** записи из **`TelegramAuth.limit_map`** переносятся в **`CoreInit.conf.WAF.limit_map`**. Перед добавлением из глобального списка удаляются правила с тем же **`pattern`** (точное строковое совпадение, `Ordinal`). Core выбирает **первое** подходящее по пути правило; иначе — глобальный **`WAF.limit_req`**.
|
||||
|
||||
Запрос может **не попадать** под лимит, если для него выставлены **`IsLocalRequest`** / **`IsAnonymousRequest`** — см. порядок middleware в хосте.
|
||||
|
||||
Шаблон по умолчанию в примере: `^/tg/auth` с лимитом запросов с IP. Переопределение: [`init.merge.example.json`](init.merge.example.json).
|
||||
|
||||
---
|
||||
|
||||
## Хранилище (`data_dir`)
|
||||
|
||||
| Файл | Содержимое |
|
||||
|------|------------|
|
||||
| `users.json` | Пользователи Telegram, устройства, опционально **`accs`**. |
|
||||
| `admins.json` | Служебно (legacy-импорт). |
|
||||
| `user_langs.json` | `telegramId → язык`. |
|
||||
|
||||
---
|
||||
|
||||
## HTTP API (префикс `/tg/auth`)
|
||||
|
||||
Маршруты анонимны в смысле cookie-сессии Lampac; админские мутации требуют cookie **`accspasswd`** (root) **или** заголовок **`X-TelegramAuth-Mutations-Secret`** при **непустом** `mutations_api_secret`. Если секрет пуст — заголовок не принимается, для мутаций остаётся только cookie.
|
||||
|
||||
**`POST /tg/auth/bind/complete`:** секрет обязателен только если `mutations_api_secret` не пустой.
|
||||
|
||||
### Чтение / статус
|
||||
|
||||
- `GET /tg/auth/status?uid=` — авторизован ли UID, срок, роль, число устройств.
|
||||
- `GET /tg/auth/me?uid=` — полная запись владельца UID (404 если нет).
|
||||
|
||||
### Пользователь по Telegram
|
||||
|
||||
- `GET /tg/auth/user/by-telegram?telegramId=` — сводка для бота.
|
||||
- `GET /tg/auth/devices?telegramId=` — список устройств.
|
||||
|
||||
### Привязка и устройства
|
||||
|
||||
- `POST /tg/auth/bind/start` — `{ "uid" }`.
|
||||
- `POST /tg/auth/bind/complete` — `{ "uid", "telegramId", "username?", "deviceName?" }`.
|
||||
- `POST /tg/auth/device/name` — `{ "uid", "name?" }` (плагин Lampa после успешного статуса).
|
||||
- `POST /tg/auth/device/unbind` — `{ "telegramId", "uid" }` (без секрета мутаций).
|
||||
- `POST /tg/auth/device/reactivate` — `{ "telegramId", "uid" }` (403 если pending/disabled).
|
||||
|
||||
### Административные
|
||||
|
||||
- `GET /tg/auth/admin/users`
|
||||
- `GET /tg/auth/admin/user?telegramId=`
|
||||
- `POST /tg/auth/admin/user/patch` — патч профиля и **`accs`**, см. код/бот `/setuser`.
|
||||
- `POST /tg/auth/admin/user/disabled` — `{ "telegramId", "disabled" }` (admin нельзя отключить).
|
||||
- `POST /tg/auth/admin/user/pending` — `{ "telegramId", "approve" }` (отклонение удаляет пользователя из `data_dir` и снимает UID с accsdb).
|
||||
- `POST /tg/auth/import`
|
||||
- `POST /tg/auth/devices/cleanup`
|
||||
|
||||
---
|
||||
|
||||
## Безопасность
|
||||
|
||||
- Не коммитьте **`mutations_api_secret`**. В проде лучше ненулевой секрет.
|
||||
- Расширяйте **`limit_map`** при необходимости.
|
||||
- GET с `uid` / `telegramId` раскрывают факт привязки — при жёсткой модели угроз ограничивайте сеть/прокси.
|
||||
|
||||
---
|
||||
|
||||
## Связь с ботом и клиентом
|
||||
|
||||
- Бот ходит на **`TelegramAuthBot.lampac_base_url`** + пути `tg/auth/...`. Секрет должен совпадать с **`TelegramAuth.mutations_api_secret`**.
|
||||
- Клиент Lampa: [Community README — плагины](../README.md).
|
||||
|
||||
---
|
||||
|
||||
## Legacy-импорт
|
||||
|
||||
Каталог **`legacy_import_path`**: минимум **`tokens.json`**; опционально `admin_ids.json`, `user_langs.json`. Форматы — `TelegramAuthStore.ImportFromLegacy`. Запись с `approved_by` = **`registration-pending`** → pending/disabled как при auto-provision без немедленной активации.
|
||||
@@ -0,0 +1,168 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Shared.Models.Base;
|
||||
using TelegramAuth.Models;
|
||||
|
||||
namespace TelegramAuth.Services
|
||||
{
|
||||
internal static class AccsdbUidSync
|
||||
{
|
||||
const string UsersFileName = "users.json";
|
||||
static readonly object FileLock = new object();
|
||||
|
||||
static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings
|
||||
{
|
||||
Formatting = Formatting.Indented,
|
||||
NullValueHandling = NullValueHandling.Ignore
|
||||
};
|
||||
|
||||
public static void UpsertTelegramDevice(string deviceUid, TelegramUserRecord tgUser, DateTime expiresLocal, int groupAdmin, int groupUser)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceUid) || tgUser == null)
|
||||
return;
|
||||
|
||||
var row = BuildAccsUserForDevice(deviceUid.Trim(), tgUser, expiresLocal, groupAdmin, groupUser);
|
||||
lock (FileLock)
|
||||
{
|
||||
EnsureFileExistsUnlocked();
|
||||
|
||||
var list = ReadListUnlocked();
|
||||
var key = row.id;
|
||||
var found = list.FirstOrDefault(u =>
|
||||
(u.id != null && string.Equals(u.id, key, StringComparison.OrdinalIgnoreCase)) ||
|
||||
(u.ids != null && u.ids.Any(id => string.Equals(id, key, StringComparison.OrdinalIgnoreCase))));
|
||||
|
||||
if (found != null)
|
||||
{
|
||||
found.id = row.id;
|
||||
found.ids = row.ids ?? new List<string>();
|
||||
found.IsPasswd = row.IsPasswd;
|
||||
found.expires = row.expires;
|
||||
found.group = row.group;
|
||||
found.ban = row.ban;
|
||||
found.ban_msg = row.ban_msg;
|
||||
found.comment = row.comment;
|
||||
found.@params = row.@params;
|
||||
}
|
||||
else
|
||||
list.Add(row);
|
||||
|
||||
WriteListUnlocked(list);
|
||||
}
|
||||
}
|
||||
|
||||
static AccsUser BuildAccsUserForDevice(string deviceUid, TelegramUserRecord tg, DateTime expiresLocal, int groupAdmin, int groupUser)
|
||||
{
|
||||
var accs = tg.Accs;
|
||||
var group = accs?.group ?? (string.Equals(tg.Role, "admin", StringComparison.OrdinalIgnoreCase) ? groupAdmin : groupUser);
|
||||
var isPasswd = accs?.IsPasswd ?? false;
|
||||
var banned = accs?.ban == true;
|
||||
|
||||
var ids = accs?.ids != null && accs.ids.Count > 0
|
||||
? accs.ids.Where(s => !string.IsNullOrWhiteSpace(s)).Select(s => s.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToList()
|
||||
: new List<string>();
|
||||
|
||||
var paramDict = new Dictionary<string, object>(StringComparer.Ordinal);
|
||||
if (accs?.@params != null)
|
||||
{
|
||||
foreach (var kv in accs.@params)
|
||||
paramDict[kv.Key] = kv.Value;
|
||||
}
|
||||
|
||||
paramDict["telegram_id"] = tg.TelegramId;
|
||||
if (!string.IsNullOrWhiteSpace(tg.TgUsername))
|
||||
paramDict["telegram_username"] = tg.TgUsername.Trim();
|
||||
|
||||
string comment;
|
||||
if (!string.IsNullOrWhiteSpace(accs?.comment))
|
||||
comment = accs.comment.Trim();
|
||||
else if (!string.IsNullOrWhiteSpace(tg.TgUsername))
|
||||
comment = $"telegram:{tg.TelegramId} @{tg.TgUsername.Trim()}";
|
||||
else
|
||||
comment = $"telegram:{tg.TelegramId}";
|
||||
|
||||
return new AccsUser
|
||||
{
|
||||
id = deviceUid,
|
||||
ids = ids,
|
||||
expires = expiresLocal,
|
||||
group = group,
|
||||
ban = banned,
|
||||
ban_msg = string.IsNullOrWhiteSpace(accs?.ban_msg) ? null : accs.ban_msg.Trim(),
|
||||
comment = comment,
|
||||
IsPasswd = isPasswd,
|
||||
@params = paramDict
|
||||
};
|
||||
}
|
||||
|
||||
public static void RemoveUid(string deviceUid)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceUid))
|
||||
return;
|
||||
|
||||
var key = deviceUid.Trim();
|
||||
lock (FileLock)
|
||||
{
|
||||
if (!File.Exists(Path.GetFullPath(UsersFileName)))
|
||||
return;
|
||||
|
||||
var list = ReadListUnlocked();
|
||||
foreach (var u in list)
|
||||
{
|
||||
if (u.ids != null && u.ids.Count > 0)
|
||||
u.ids = u.ids.Where(id => !string.Equals(id, key, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
}
|
||||
|
||||
list = list
|
||||
.Where(u => u.id == null || !string.Equals(u.id, key, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
WriteListUnlocked(list);
|
||||
}
|
||||
}
|
||||
|
||||
static void EnsureFileExistsUnlocked()
|
||||
{
|
||||
var full = Path.GetFullPath(UsersFileName);
|
||||
var dir = Path.GetDirectoryName(full);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
if (!File.Exists(full))
|
||||
File.WriteAllText(full, "[]" + Environment.NewLine);
|
||||
}
|
||||
|
||||
static List<AccsUser> ReadListUnlocked()
|
||||
{
|
||||
try
|
||||
{
|
||||
var full = Path.GetFullPath(UsersFileName);
|
||||
if (!File.Exists(full))
|
||||
return new List<AccsUser>();
|
||||
|
||||
var txt = File.ReadAllText(full);
|
||||
if (string.IsNullOrWhiteSpace(txt))
|
||||
return new List<AccsUser>();
|
||||
|
||||
return JsonConvert.DeserializeObject<List<AccsUser>>(txt) ?? new List<AccsUser>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<AccsUser>();
|
||||
}
|
||||
}
|
||||
|
||||
static void WriteListUnlocked(List<AccsUser> list)
|
||||
{
|
||||
var full = Path.GetFullPath(UsersFileName);
|
||||
var dir = Path.GetDirectoryName(full);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
File.WriteAllText(full, JsonConvert.SerializeObject(list, JsonSettings));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
|
||||
namespace TelegramAuth.Services
|
||||
{
|
||||
public enum TelegramAuthBindFailureKind
|
||||
{
|
||||
UserNotFound,
|
||||
UserDisabled
|
||||
}
|
||||
|
||||
public sealed class TelegramAuthBindException : Exception
|
||||
{
|
||||
public TelegramAuthBindFailureKind FailureKind { get; }
|
||||
|
||||
public TelegramAuthBindException(TelegramAuthBindFailureKind failureKind, string message)
|
||||
: base(message)
|
||||
{
|
||||
FailureKind = failureKind;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using TelegramAuth.Models;
|
||||
|
||||
namespace TelegramAuth.Services
|
||||
{
|
||||
public partial class TelegramAuthStore
|
||||
{
|
||||
public enum SetUserDisabledOutcome
|
||||
{
|
||||
Ok,
|
||||
NotFound,
|
||||
CannotDisableAdmin
|
||||
}
|
||||
|
||||
public SetUserDisabledOutcome TrySetUserDisabled(string telegramId, bool disabled)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(telegramId))
|
||||
return SetUserDisabledOutcome.NotFound;
|
||||
|
||||
var users = GetUsers();
|
||||
var user = users.FirstOrDefault(u => string.Equals(u.TelegramId, telegramId, StringComparison.Ordinal));
|
||||
if (user == null)
|
||||
return SetUserDisabledOutcome.NotFound;
|
||||
|
||||
if (disabled && string.Equals(user.Role, "admin", StringComparison.OrdinalIgnoreCase))
|
||||
return SetUserDisabledOutcome.CannotDisableAdmin;
|
||||
|
||||
user.Disabled = disabled;
|
||||
if (disabled)
|
||||
{
|
||||
user.RegistrationPending = false;
|
||||
if (ShouldSyncAccsdb() && user.Devices != null)
|
||||
{
|
||||
foreach (var d in user.Devices)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(d.Uid))
|
||||
AccsdbUidSync.RemoveUid(d.Uid);
|
||||
}
|
||||
}
|
||||
|
||||
if (user.Devices != null)
|
||||
{
|
||||
foreach (var d in user.Devices)
|
||||
d.Active = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
user.RegistrationPending = false;
|
||||
|
||||
SaveUsers(users);
|
||||
|
||||
if (!disabled && ShouldSyncAccsdb() && IsActive(user))
|
||||
{
|
||||
foreach (var d in user.Devices ?? new List<DeviceRecord>())
|
||||
{
|
||||
if (d.Active && !string.IsNullOrWhiteSpace(d.Uid))
|
||||
SyncDeviceToAccsdb(user, d.Uid);
|
||||
}
|
||||
}
|
||||
|
||||
return SetUserDisabledOutcome.Ok;
|
||||
}
|
||||
|
||||
public enum PendingDecisionOutcome
|
||||
{
|
||||
Ok,
|
||||
NotFound,
|
||||
NotPending,
|
||||
CannotRejectAdmin
|
||||
}
|
||||
|
||||
public PendingDecisionOutcome TryApproveRegistrationPending(string telegramId)
|
||||
{
|
||||
var tid = telegramId?.Trim();
|
||||
if (string.IsNullOrEmpty(tid))
|
||||
return PendingDecisionOutcome.NotFound;
|
||||
|
||||
var users = GetUsers();
|
||||
var user = users.FirstOrDefault(u => string.Equals(u.TelegramId, tid, StringComparison.Ordinal));
|
||||
if (user == null)
|
||||
return PendingDecisionOutcome.NotFound;
|
||||
if (!IsRegistrationPending(user))
|
||||
return PendingDecisionOutcome.NotPending;
|
||||
|
||||
user.RegistrationPending = false;
|
||||
user.Disabled = false;
|
||||
user.ApprovedBy = "admin-approved";
|
||||
SaveUsers(users);
|
||||
if (ShouldSyncAccsdb() && IsActive(user))
|
||||
{
|
||||
foreach (var d in user.Devices ?? new List<DeviceRecord>())
|
||||
{
|
||||
if (d.Active && !string.IsNullOrWhiteSpace(d.Uid))
|
||||
SyncDeviceToAccsdb(user, d.Uid);
|
||||
}
|
||||
}
|
||||
|
||||
return PendingDecisionOutcome.Ok;
|
||||
}
|
||||
|
||||
public PendingDecisionOutcome TryRejectRegistrationPending(string telegramId)
|
||||
{
|
||||
var tid = telegramId?.Trim();
|
||||
if (string.IsNullOrEmpty(tid))
|
||||
return PendingDecisionOutcome.NotFound;
|
||||
|
||||
var users = GetUsers();
|
||||
var user = users.FirstOrDefault(u => string.Equals(u.TelegramId, tid, StringComparison.Ordinal));
|
||||
if (user == null)
|
||||
return PendingDecisionOutcome.NotFound;
|
||||
if (!IsRegistrationPending(user))
|
||||
return PendingDecisionOutcome.NotPending;
|
||||
if (string.Equals(user.Role, "admin", StringComparison.OrdinalIgnoreCase))
|
||||
return PendingDecisionOutcome.CannotRejectAdmin;
|
||||
|
||||
if (ShouldSyncAccsdb())
|
||||
{
|
||||
foreach (var d in user.Devices ?? new List<DeviceRecord>())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(d.Uid))
|
||||
AccsdbUidSync.RemoveUid(d.Uid);
|
||||
}
|
||||
}
|
||||
|
||||
users.Remove(user);
|
||||
SaveUsers(users);
|
||||
return PendingDecisionOutcome.Ok;
|
||||
}
|
||||
|
||||
internal const int DeviceDisplayNameMaxLen = 500;
|
||||
|
||||
public enum SetDeviceDisplayNameOutcome
|
||||
{
|
||||
Ok,
|
||||
InvalidUid,
|
||||
NotFoundOrInactive
|
||||
}
|
||||
|
||||
public enum ReactivateDeviceOutcome
|
||||
{
|
||||
Ok,
|
||||
UserNotFound,
|
||||
UserDisabled,
|
||||
DeviceNotFound
|
||||
}
|
||||
|
||||
public enum UnbindDeviceOutcome
|
||||
{
|
||||
Ok,
|
||||
UserNotFound,
|
||||
DeviceNotFound
|
||||
}
|
||||
|
||||
public UnbindDeviceOutcome TryUnbindDevice(string telegramId, string uid)
|
||||
{
|
||||
var tid = telegramId?.Trim();
|
||||
var u = uid?.Trim();
|
||||
if (string.IsNullOrEmpty(tid) || string.IsNullOrEmpty(u))
|
||||
return UnbindDeviceOutcome.DeviceNotFound;
|
||||
|
||||
var users = GetUsers();
|
||||
var user = users.FirstOrDefault(x => string.Equals(x.TelegramId, tid, StringComparison.Ordinal));
|
||||
if (user == null)
|
||||
return UnbindDeviceOutcome.UserNotFound;
|
||||
|
||||
var device = user.Devices?.FirstOrDefault(d => string.Equals(d.Uid, u, StringComparison.OrdinalIgnoreCase));
|
||||
if (device == null)
|
||||
return UnbindDeviceOutcome.DeviceNotFound;
|
||||
|
||||
device.Active = false;
|
||||
SaveUsers(users);
|
||||
if (ShouldSyncAccsdb())
|
||||
AccsdbUidSync.RemoveUid(u);
|
||||
return UnbindDeviceOutcome.Ok;
|
||||
}
|
||||
|
||||
public ReactivateDeviceOutcome TryReactivateDevice(string telegramId, string uid)
|
||||
{
|
||||
var tid = telegramId?.Trim();
|
||||
var u = uid?.Trim();
|
||||
if (string.IsNullOrEmpty(tid) || string.IsNullOrEmpty(u))
|
||||
return ReactivateDeviceOutcome.DeviceNotFound;
|
||||
|
||||
var users = GetUsers();
|
||||
var user = users.FirstOrDefault(x => string.Equals(x.TelegramId, tid, StringComparison.Ordinal));
|
||||
if (user == null)
|
||||
return ReactivateDeviceOutcome.UserNotFound;
|
||||
if (IsRegistrationPending(user) || user.Disabled)
|
||||
return ReactivateDeviceOutcome.UserDisabled;
|
||||
|
||||
var device = user.Devices?.FirstOrDefault(d => string.Equals(d.Uid, u, StringComparison.OrdinalIgnoreCase));
|
||||
if (device == null)
|
||||
return ReactivateDeviceOutcome.DeviceNotFound;
|
||||
if (device.Active)
|
||||
return ReactivateDeviceOutcome.Ok;
|
||||
|
||||
CleanupInactiveDevices(users, 90);
|
||||
|
||||
BumpOldestActiveDeviceIfAtDeviceLimit(user);
|
||||
|
||||
device.Active = true;
|
||||
device.LastSeenAt = DateTime.UtcNow;
|
||||
SaveUsers(users);
|
||||
if (ShouldSyncAccsdb() && IsActive(user) && device.Active)
|
||||
SyncDeviceToAccsdb(user, u);
|
||||
return ReactivateDeviceOutcome.Ok;
|
||||
}
|
||||
|
||||
public SetDeviceDisplayNameOutcome TrySetActiveDeviceDisplayName(string uid, string? name)
|
||||
{
|
||||
var u = uid?.Trim();
|
||||
if (string.IsNullOrEmpty(u))
|
||||
return SetDeviceDisplayNameOutcome.InvalidUid;
|
||||
|
||||
string? resolved = null;
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
resolved = name.Trim();
|
||||
if (resolved.Length > DeviceDisplayNameMaxLen)
|
||||
resolved = resolved.Substring(0, DeviceDisplayNameMaxLen);
|
||||
}
|
||||
|
||||
var users = GetUsers();
|
||||
foreach (var user in users)
|
||||
{
|
||||
if (!IsActive(user))
|
||||
continue;
|
||||
|
||||
var dev = user.Devices?.FirstOrDefault(d => d.Active && string.Equals(d.Uid, u, StringComparison.OrdinalIgnoreCase));
|
||||
if (dev == null)
|
||||
continue;
|
||||
|
||||
dev.Name = resolved;
|
||||
SaveUsers(users);
|
||||
return SetDeviceDisplayNameOutcome.Ok;
|
||||
}
|
||||
|
||||
return SetDeviceDisplayNameOutcome.NotFoundOrInactive;
|
||||
}
|
||||
|
||||
public int GetMaxDevices(TelegramUserRecord user)
|
||||
{
|
||||
if (user != null && string.Equals(user.Role, "admin", StringComparison.OrdinalIgnoreCase))
|
||||
return AdminUnlimitedDevices;
|
||||
|
||||
int max = _conf.max_active_devices_per_user;
|
||||
return max > 0 ? max : MaxActiveDevicesPerUser;
|
||||
}
|
||||
|
||||
public const string BindUserNotFoundMessage = "user not found";
|
||||
public const string BindUserDisabledMessage = "user disabled";
|
||||
|
||||
public BindDeviceOutcome BindDevice(string telegramId, string uid, string? telegramUsername = null, string? deviceDisplayName = null, string source = "manual")
|
||||
{
|
||||
var outcome = new BindDeviceOutcome();
|
||||
var users = GetUsers();
|
||||
var user = users.FirstOrDefault(u => u.TelegramId == telegramId);
|
||||
if (user == null)
|
||||
{
|
||||
if (!_conf.auto_provision_users)
|
||||
throw new TelegramAuthBindException(TelegramAuthBindFailureKind.UserNotFound, BindUserNotFoundMessage);
|
||||
|
||||
DateTime? expires = null;
|
||||
var days = _conf.auto_provision_expires_days;
|
||||
if (days > 0)
|
||||
expires = DateTime.UtcNow.AddDays(days);
|
||||
|
||||
var role = string.IsNullOrWhiteSpace(_conf.auto_provision_role)
|
||||
? "user"
|
||||
: _conf.auto_provision_role.Trim();
|
||||
if (string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase))
|
||||
role = "user";
|
||||
|
||||
var pendingApproval = !_conf.auto_provision_activate_immediately;
|
||||
user = new TelegramUserRecord
|
||||
{
|
||||
TelegramId = telegramId,
|
||||
TgUsername = telegramUsername?.Trim() ?? "",
|
||||
Role = role,
|
||||
Lang = string.IsNullOrWhiteSpace(_conf.auto_provision_lang) ? "ru" : _conf.auto_provision_lang.Trim(),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
ExpiresAt = expires,
|
||||
ApprovedBy = pendingApproval ? RegistrationPendingApprovedByMarker : "auto-provision",
|
||||
Disabled = pendingApproval,
|
||||
RegistrationPending = pendingApproval,
|
||||
Devices = new List<DeviceRecord>()
|
||||
};
|
||||
users.Add(user);
|
||||
outcome.NewUserProvisioned = true;
|
||||
outcome.PendingAdminApproval = pendingApproval;
|
||||
}
|
||||
else if (user.Disabled && !IsRegistrationPending(user))
|
||||
{
|
||||
throw new TelegramAuthBindException(TelegramAuthBindFailureKind.UserDisabled, BindUserDisabledMessage);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(telegramUsername))
|
||||
{
|
||||
var trimmed = telegramUsername.Trim();
|
||||
if (!string.Equals(user.TgUsername, trimmed, StringComparison.Ordinal))
|
||||
user.TgUsername = trimmed;
|
||||
}
|
||||
|
||||
string? resolvedDeviceName = null;
|
||||
if (!string.IsNullOrWhiteSpace(deviceDisplayName))
|
||||
{
|
||||
var t = deviceDisplayName.Trim();
|
||||
resolvedDeviceName = t.Length > DeviceDisplayNameMaxLen ? t.Substring(0, DeviceDisplayNameMaxLen) : t;
|
||||
}
|
||||
|
||||
CleanupInactiveDevices(users, 90);
|
||||
|
||||
var existing = user.Devices.FirstOrDefault(d => string.Equals(d.Uid, uid, StringComparison.OrdinalIgnoreCase));
|
||||
if (existing == null)
|
||||
{
|
||||
BumpOldestActiveDeviceIfAtDeviceLimit(user);
|
||||
|
||||
user.Devices.Add(new DeviceRecord
|
||||
{
|
||||
Uid = uid,
|
||||
Name = resolvedDeviceName,
|
||||
LinkedAt = DateTime.UtcNow,
|
||||
LastSeenAt = DateTime.UtcNow,
|
||||
Active = true,
|
||||
Source = source
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!existing.Active)
|
||||
BumpOldestActiveDeviceIfAtDeviceLimit(user);
|
||||
|
||||
existing.Active = true;
|
||||
if (!string.IsNullOrWhiteSpace(resolvedDeviceName))
|
||||
existing.Name = resolvedDeviceName;
|
||||
existing.LastSeenAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
SaveUsers(users);
|
||||
SyncAccsdbForBind(user, uid);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
public AuthStatusResponse GetStatus(string uid)
|
||||
{
|
||||
var user = FindByUid(uid);
|
||||
if (user == null)
|
||||
{
|
||||
return new AuthStatusResponse
|
||||
{
|
||||
Authorized = false,
|
||||
Pending = true,
|
||||
Message = "Устройство не привязано. Нужна авторизация через Telegram."
|
||||
};
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
if (IsRegistrationPending(user))
|
||||
{
|
||||
return new AuthStatusResponse
|
||||
{
|
||||
Authorized = false,
|
||||
Pending = true,
|
||||
RegistrationPending = true,
|
||||
Message = "Аккаунт ожидает подтверждения администратора.",
|
||||
TelegramId = user.TelegramId,
|
||||
Username = user.TgUsername,
|
||||
Role = user.Role,
|
||||
ExpiresAt = user.ExpiresAt,
|
||||
DeviceCount = user.Devices.Count(d => d.Active)
|
||||
};
|
||||
}
|
||||
|
||||
if (user.Disabled)
|
||||
{
|
||||
return new AuthStatusResponse
|
||||
{
|
||||
Authorized = false,
|
||||
Pending = false,
|
||||
RegistrationPending = false,
|
||||
Message = "Доступ отключён администратором.",
|
||||
TelegramId = user.TelegramId,
|
||||
Username = user.TgUsername,
|
||||
Role = user.Role,
|
||||
ExpiresAt = user.ExpiresAt,
|
||||
DeviceCount = user.Devices.Count(d => d.Active)
|
||||
};
|
||||
}
|
||||
|
||||
var expired = user.ExpiresAt.HasValue && user.ExpiresAt.Value.ToUniversalTime() < now;
|
||||
return new AuthStatusResponse
|
||||
{
|
||||
Authorized = !expired,
|
||||
Pending = false,
|
||||
RegistrationPending = false,
|
||||
Message = expired ? "Срок доступа истёк." : "OK",
|
||||
TelegramId = user.TelegramId,
|
||||
Username = user.TgUsername,
|
||||
Role = user.Role,
|
||||
ExpiresAt = user.ExpiresAt,
|
||||
DeviceCount = user.Devices.Count(d => d.Active)
|
||||
};
|
||||
}
|
||||
|
||||
public int CleanupInactiveDevices(List<TelegramUserRecord>? users = null, int olderThanDays = 90)
|
||||
{
|
||||
users ??= GetUsers();
|
||||
var threshold = DateTime.UtcNow.AddDays(-olderThanDays);
|
||||
var removed = 0;
|
||||
|
||||
foreach (var user in users)
|
||||
{
|
||||
if (user.Devices == null)
|
||||
continue;
|
||||
|
||||
var before = user.Devices.Count;
|
||||
user.Devices = user.Devices
|
||||
.Where(d => d.Active || ((d.LastSeenAt ?? d.LinkedAt) >= threshold))
|
||||
.ToList();
|
||||
|
||||
var maxDevices = GetMaxDevices(user);
|
||||
if (maxDevices > 0)
|
||||
{
|
||||
var activeDevices = user.Devices.Where(d => d.Active).OrderByDescending(d => d.LastSeenAt ?? d.LinkedAt).ToList();
|
||||
if (activeDevices.Count > maxDevices)
|
||||
{
|
||||
foreach (var extra in activeDevices.Skip(maxDevices))
|
||||
{
|
||||
extra.Active = false;
|
||||
RemoveUidFromAccsdbIfSync(extra.Uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removed += before - user.Devices.Count;
|
||||
}
|
||||
|
||||
SaveUsers(users);
|
||||
return removed;
|
||||
}
|
||||
|
||||
public enum AdminPatchUserOutcome
|
||||
{
|
||||
Ok,
|
||||
NotFound,
|
||||
InvalidPayload
|
||||
}
|
||||
|
||||
public AdminPatchUserOutcome TryAdminPatchUser(string telegramId, JObject body, out string? errorDetail)
|
||||
{
|
||||
errorDetail = null;
|
||||
if (string.IsNullOrWhiteSpace(telegramId))
|
||||
{
|
||||
errorDetail = "telegramId is required";
|
||||
return AdminPatchUserOutcome.InvalidPayload;
|
||||
}
|
||||
|
||||
if (body == null)
|
||||
{
|
||||
errorDetail = "body is required";
|
||||
return AdminPatchUserOutcome.InvalidPayload;
|
||||
}
|
||||
|
||||
var users = GetUsers();
|
||||
var tid = telegramId.Trim();
|
||||
var user = users.FirstOrDefault(u => string.Equals(u.TelegramId, tid, StringComparison.Ordinal));
|
||||
if (user == null)
|
||||
return AdminPatchUserOutcome.NotFound;
|
||||
|
||||
if (body.ContainsKey("expiresAt"))
|
||||
{
|
||||
var t = body["expiresAt"];
|
||||
if (t == null || t.Type == JTokenType.Null)
|
||||
user.ExpiresAt = null;
|
||||
else if (t.Type == JTokenType.String && string.IsNullOrWhiteSpace(t.Value<string>()))
|
||||
user.ExpiresAt = null;
|
||||
else
|
||||
{
|
||||
var s = t.Type == JTokenType.String ? t.Value<string>() : t.ToString();
|
||||
if (!DateTime.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt))
|
||||
{
|
||||
errorDetail = "expiresAt: invalid date";
|
||||
return AdminPatchUserOutcome.InvalidPayload;
|
||||
}
|
||||
|
||||
user.ExpiresAt = dt;
|
||||
}
|
||||
}
|
||||
|
||||
if (body.ContainsKey("lang"))
|
||||
{
|
||||
var s = body["lang"]?.Value<string>()?.Trim();
|
||||
if (!string.IsNullOrEmpty(s))
|
||||
user.Lang = s;
|
||||
}
|
||||
|
||||
if (body.ContainsKey("role"))
|
||||
{
|
||||
var r = body["role"]?.Value<string>()?.Trim();
|
||||
if (string.IsNullOrEmpty(r))
|
||||
{
|
||||
errorDetail = "role: empty";
|
||||
return AdminPatchUserOutcome.InvalidPayload;
|
||||
}
|
||||
|
||||
if (!string.Equals(r, "admin", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(r, "user", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errorDetail = "role: use user or admin";
|
||||
return AdminPatchUserOutcome.InvalidPayload;
|
||||
}
|
||||
|
||||
user.Role = string.Equals(r, "admin", StringComparison.OrdinalIgnoreCase) ? "admin" : "user";
|
||||
}
|
||||
|
||||
if (body["accs"] is JObject accsJo)
|
||||
{
|
||||
user.Accs ??= new TelegramAccsProfile();
|
||||
MergeAccsPatch(user.Accs, accsJo, out var accsErr);
|
||||
if (accsErr != null)
|
||||
{
|
||||
errorDetail = accsErr;
|
||||
return AdminPatchUserOutcome.InvalidPayload;
|
||||
}
|
||||
}
|
||||
|
||||
if (body["accsRemove"] is JArray rem)
|
||||
{
|
||||
user.Accs ??= new TelegramAccsProfile();
|
||||
foreach (var x in rem)
|
||||
{
|
||||
var key = x.Value<string>()?.Trim();
|
||||
if (!string.IsNullOrEmpty(key))
|
||||
ApplyAccsRemove(user.Accs, key);
|
||||
}
|
||||
}
|
||||
|
||||
if (body["paramsRemove"] is JArray pr)
|
||||
{
|
||||
user.Accs ??= new TelegramAccsProfile();
|
||||
user.Accs.@params ??= new Dictionary<string, object>(StringComparer.Ordinal);
|
||||
foreach (var x in pr)
|
||||
{
|
||||
var k = x.Value<string>();
|
||||
if (!string.IsNullOrEmpty(k))
|
||||
user.Accs.@params.Remove(k);
|
||||
}
|
||||
}
|
||||
|
||||
SaveUsers(users);
|
||||
ResyncUserDevicesToAccsdb(user);
|
||||
return AdminPatchUserOutcome.Ok;
|
||||
}
|
||||
|
||||
static void MergeAccsPatch(TelegramAccsProfile accs, JObject jo, out string? error)
|
||||
{
|
||||
error = null;
|
||||
if (jo.ContainsKey("group"))
|
||||
{
|
||||
var t = jo["group"];
|
||||
if (t == null || t.Type == JTokenType.Null)
|
||||
accs.group = null;
|
||||
else if (t.Type == JTokenType.Integer || t.Type == JTokenType.Float)
|
||||
accs.group = (int)t.Value<long>();
|
||||
else
|
||||
{
|
||||
error = "accs.group: expected integer or null";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (jo.ContainsKey("IsPasswd"))
|
||||
{
|
||||
var t = jo["IsPasswd"];
|
||||
if (t == null || t.Type == JTokenType.Null)
|
||||
accs.IsPasswd = null;
|
||||
else if (t.Type != JTokenType.Boolean)
|
||||
{
|
||||
error = "accs.IsPasswd: expected boolean or null";
|
||||
return;
|
||||
}
|
||||
else
|
||||
accs.IsPasswd = t.Value<bool>();
|
||||
}
|
||||
|
||||
if (jo.ContainsKey("ban"))
|
||||
{
|
||||
var t = jo["ban"];
|
||||
if (t == null || t.Type == JTokenType.Null)
|
||||
accs.ban = null;
|
||||
else if (t.Type != JTokenType.Boolean)
|
||||
{
|
||||
error = "accs.ban: expected boolean or null";
|
||||
return;
|
||||
}
|
||||
else
|
||||
accs.ban = t.Value<bool>();
|
||||
}
|
||||
|
||||
if (jo.ContainsKey("ban_msg"))
|
||||
{
|
||||
var t = jo["ban_msg"];
|
||||
if (t == null || t.Type == JTokenType.Null)
|
||||
accs.ban_msg = null;
|
||||
else
|
||||
{
|
||||
var s = t.Value<string>()?.Trim();
|
||||
accs.ban_msg = string.IsNullOrEmpty(s) ? null : s;
|
||||
}
|
||||
}
|
||||
|
||||
if (jo.ContainsKey("comment"))
|
||||
{
|
||||
var t = jo["comment"];
|
||||
if (t == null || t.Type == JTokenType.Null)
|
||||
accs.comment = null;
|
||||
else
|
||||
{
|
||||
var s = t.Value<string>()?.Trim();
|
||||
accs.comment = string.IsNullOrEmpty(s) ? null : s;
|
||||
}
|
||||
}
|
||||
|
||||
if (jo.ContainsKey("ids"))
|
||||
{
|
||||
var arr = jo["ids"] as JArray;
|
||||
if (arr == null || arr.Count == 0)
|
||||
accs.ids = null;
|
||||
else
|
||||
{
|
||||
accs.ids = arr
|
||||
.Select(x => x?.ToString())
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(s => s!.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
if (jo["params"] is JObject pjo)
|
||||
{
|
||||
accs.@params ??= new Dictionary<string, object>(StringComparer.Ordinal);
|
||||
foreach (var prop in pjo.Properties())
|
||||
{
|
||||
if (prop.Value == null || prop.Value.Type == JTokenType.Null)
|
||||
accs.@params.Remove(prop.Name);
|
||||
else
|
||||
accs.@params[prop.Name] = prop.Value.Type == JTokenType.String
|
||||
? (prop.Value.Value<string>() ?? "")
|
||||
: prop.Value.Type == JTokenType.Integer
|
||||
? prop.Value.Value<long>()
|
||||
: prop.Value.Type == JTokenType.Float
|
||||
? prop.Value.Value<double>()
|
||||
: prop.Value.Type == JTokenType.Boolean
|
||||
? prop.Value.Value<bool>()
|
||||
: prop.Value.ToString(Formatting.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void ApplyAccsRemove(TelegramAccsProfile accs, string key)
|
||||
{
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "group":
|
||||
accs.group = null;
|
||||
break;
|
||||
case "ispasswd":
|
||||
case "is_passwd":
|
||||
case "passwd":
|
||||
accs.IsPasswd = null;
|
||||
break;
|
||||
case "ban":
|
||||
accs.ban = null;
|
||||
break;
|
||||
case "ban_msg":
|
||||
case "banmsg":
|
||||
accs.ban_msg = null;
|
||||
break;
|
||||
case "comment":
|
||||
accs.comment = null;
|
||||
break;
|
||||
case "ids":
|
||||
accs.ids = null;
|
||||
break;
|
||||
case "params":
|
||||
accs.@params = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Shared;
|
||||
using TelegramAuth.Models;
|
||||
|
||||
namespace TelegramAuth.Services
|
||||
{
|
||||
public partial class TelegramAuthStore
|
||||
{
|
||||
readonly TelegramAuthConf _conf;
|
||||
readonly string baseDir;
|
||||
readonly string usersPath;
|
||||
readonly string adminsPath;
|
||||
readonly string langsPath;
|
||||
public const int MaxActiveDevicesPerUser = 5;
|
||||
public const int AdminUnlimitedDevices = -1;
|
||||
|
||||
public TelegramAuthStore(TelegramAuthConf conf)
|
||||
{
|
||||
_conf = conf ?? new TelegramAuthConf();
|
||||
var rel = string.IsNullOrWhiteSpace(_conf.data_dir)
|
||||
? Path.Combine("database", "tgauth")
|
||||
: _conf.data_dir.Trim().TrimStart('/', '\\');
|
||||
baseDir = Path.IsPathRooted(rel)
|
||||
? rel
|
||||
: Path.Combine(AppContext.BaseDirectory, rel);
|
||||
usersPath = Path.Combine(baseDir, "users.json");
|
||||
adminsPath = Path.Combine(baseDir, "admins.json");
|
||||
langsPath = Path.Combine(baseDir, "user_langs.json");
|
||||
}
|
||||
|
||||
public void EnsureStorage()
|
||||
{
|
||||
Directory.CreateDirectory(baseDir);
|
||||
if (!File.Exists(usersPath)) File.WriteAllText(usersPath, "[]");
|
||||
if (!File.Exists(adminsPath)) File.WriteAllText(adminsPath, "[]");
|
||||
if (!File.Exists(langsPath)) File.WriteAllText(langsPath, "{}");
|
||||
}
|
||||
|
||||
public void EnsureOwnerUsersAtStartup()
|
||||
{
|
||||
var ids = _conf.owner_telegram_ids;
|
||||
if (ids == null || ids.Length == 0)
|
||||
return;
|
||||
|
||||
var users = GetUsers();
|
||||
var changed = false;
|
||||
var lang = string.IsNullOrWhiteSpace(_conf.auto_provision_lang) ? "ru" : _conf.auto_provision_lang.Trim();
|
||||
|
||||
foreach (var ownerNumericId in ids)
|
||||
{
|
||||
var telegramId = ownerNumericId.ToString(CultureInfo.InvariantCulture);
|
||||
var user = users.FirstOrDefault(u => string.Equals(u.TelegramId, telegramId, StringComparison.Ordinal));
|
||||
if (user == null)
|
||||
{
|
||||
users.Add(new TelegramUserRecord
|
||||
{
|
||||
TelegramId = telegramId,
|
||||
TgUsername = "",
|
||||
Role = "admin",
|
||||
Lang = lang,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
ApprovedBy = "owner-config",
|
||||
Disabled = false,
|
||||
Devices = new List<DeviceRecord>()
|
||||
});
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(user.Role, "admin", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
user.Role = "admin";
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (user.Disabled)
|
||||
{
|
||||
user.Disabled = false;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(user.ApprovedBy))
|
||||
{
|
||||
user.ApprovedBy = "owner-config";
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed)
|
||||
SaveUsers(users);
|
||||
}
|
||||
|
||||
public List<TelegramUserRecord> GetUsers()
|
||||
{
|
||||
EnsureStorage();
|
||||
return JsonConvert.DeserializeObject<List<TelegramUserRecord>>(File.ReadAllText(usersPath)) ?? new List<TelegramUserRecord>();
|
||||
}
|
||||
|
||||
public void SaveUsers(List<TelegramUserRecord> users)
|
||||
{
|
||||
EnsureStorage();
|
||||
File.WriteAllText(usersPath, JsonConvert.SerializeObject(users, Formatting.Indented));
|
||||
}
|
||||
|
||||
public Dictionary<string, string> GetLangs()
|
||||
{
|
||||
EnsureStorage();
|
||||
return JsonConvert.DeserializeObject<Dictionary<string, string>>(File.ReadAllText(langsPath)) ?? new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
public TelegramUserRecord? FindByUid(string uid)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(uid)) return null;
|
||||
return GetUsers().FirstOrDefault(u => u.Devices.Any(d => d.Active && string.Equals(d.Uid, uid, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
public TelegramUserRecord? FindByTelegramId(string telegramId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(telegramId)) return null;
|
||||
return GetUsers().FirstOrDefault(u => string.Equals(u.TelegramId, telegramId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
public static bool IsRegistrationPending(TelegramUserRecord? user) => user?.RegistrationPending == true;
|
||||
|
||||
public bool IsActive(TelegramUserRecord user)
|
||||
{
|
||||
if (user == null)
|
||||
return false;
|
||||
|
||||
if (IsRegistrationPending(user))
|
||||
return false;
|
||||
|
||||
if (user.Disabled)
|
||||
return false;
|
||||
|
||||
return !user.ExpiresAt.HasValue || user.ExpiresAt.Value.ToUniversalTime() >= DateTime.UtcNow;
|
||||
}
|
||||
|
||||
bool ShouldSyncAccsdb() =>
|
||||
_conf.enable &&
|
||||
CoreInit.conf != null && CoreInit.conf.accsdb.enable;
|
||||
|
||||
void RemoveUidFromAccsdbIfSync(string deviceUid)
|
||||
{
|
||||
if (!ShouldSyncAccsdb() || string.IsNullOrWhiteSpace(deviceUid))
|
||||
return;
|
||||
AccsdbUidSync.RemoveUid(deviceUid.Trim());
|
||||
}
|
||||
|
||||
void BumpOldestActiveDeviceIfAtDeviceLimit(TelegramUserRecord user)
|
||||
{
|
||||
var maxDevices = GetMaxDevices(user);
|
||||
if (maxDevices <= 0 || user.Devices == null)
|
||||
return;
|
||||
|
||||
var activeDevices = user.Devices.Where(d => d.Active).OrderByDescending(d => d.LastSeenAt ?? d.LinkedAt).ToList();
|
||||
if (activeDevices.Count < maxDevices)
|
||||
return;
|
||||
|
||||
var oldestActive = activeDevices.Last();
|
||||
oldestActive.Active = false;
|
||||
RemoveUidFromAccsdbIfSync(oldestActive.Uid);
|
||||
}
|
||||
|
||||
DateTime ResolveAccsdbExpiresLocal(TelegramUserRecord user)
|
||||
{
|
||||
if (user.ExpiresAt.HasValue)
|
||||
{
|
||||
var v = user.ExpiresAt.Value;
|
||||
return v.Kind == DateTimeKind.Utc ? v.ToLocalTime() : v;
|
||||
}
|
||||
|
||||
var sd = CoreInit.conf?.accsdb?.shared_daytime ?? 0;
|
||||
var days = sd > 0 ? Math.Max(1, sd) : 365;
|
||||
return DateTime.Now.AddDays(days);
|
||||
}
|
||||
|
||||
void SyncDeviceToAccsdb(TelegramUserRecord user, string deviceUid)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceUid) || user == null)
|
||||
return;
|
||||
|
||||
if (!ShouldSyncAccsdb())
|
||||
return;
|
||||
|
||||
var u = deviceUid.Trim();
|
||||
|
||||
if (user.Disabled || IsRegistrationPending(user))
|
||||
{
|
||||
AccsdbUidSync.RemoveUid(u);
|
||||
return;
|
||||
}
|
||||
|
||||
AccsdbUidSync.UpsertTelegramDevice(
|
||||
u,
|
||||
user,
|
||||
ResolveAccsdbExpiresLocal(user),
|
||||
_conf.accsdb_sync_group_admin,
|
||||
_conf.accsdb_sync_group_user);
|
||||
}
|
||||
|
||||
public void ResyncUserDevicesToAccsdb(TelegramUserRecord user)
|
||||
{
|
||||
if (user == null || !ShouldSyncAccsdb())
|
||||
return;
|
||||
|
||||
if (user.Disabled || IsRegistrationPending(user))
|
||||
{
|
||||
foreach (var d in user.Devices ?? Enumerable.Empty<DeviceRecord>())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(d.Uid))
|
||||
AccsdbUidSync.RemoveUid(d.Uid.Trim());
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var d in user.Devices ?? Enumerable.Empty<DeviceRecord>())
|
||||
{
|
||||
if (d.Active && !string.IsNullOrWhiteSpace(d.Uid))
|
||||
SyncDeviceToAccsdb(user, d.Uid.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
void SyncAccsdbForBind(TelegramUserRecord user, string? deviceUid)
|
||||
{
|
||||
if (!ShouldSyncAccsdb() || string.IsNullOrWhiteSpace(deviceUid))
|
||||
return;
|
||||
|
||||
var u = deviceUid.Trim();
|
||||
|
||||
if (user.Disabled || IsRegistrationPending(user))
|
||||
{
|
||||
AccsdbUidSync.RemoveUid(u);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsActive(user))
|
||||
return;
|
||||
|
||||
var dev = user.Devices?.FirstOrDefault(d => string.Equals(d.Uid, u, StringComparison.OrdinalIgnoreCase));
|
||||
if (dev == null || !dev.Active)
|
||||
return;
|
||||
|
||||
SyncDeviceToAccsdb(user, u);
|
||||
}
|
||||
|
||||
const string RegistrationPendingApprovedByMarker = "registration-pending";
|
||||
|
||||
static bool LegacyApprovedByIsRegistrationPending(string? approvedBy)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(approvedBy))
|
||||
return false;
|
||||
return string.Equals(approvedBy.Trim(), RegistrationPendingApprovedByMarker, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public ImportResult ImportFromLegacy(string legacyBasePath)
|
||||
{
|
||||
EnsureStorage();
|
||||
|
||||
var legacyTokensPath = Path.Combine(legacyBasePath, "tokens.json");
|
||||
var legacyAdminsPath = Path.Combine(legacyBasePath, "admin_ids.json");
|
||||
var legacyLangsPath = Path.Combine(legacyBasePath, "user_langs.json");
|
||||
|
||||
var users = new Dictionary<string, TelegramUserRecord>(StringComparer.Ordinal);
|
||||
var langs = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var result = new ImportResult();
|
||||
|
||||
if (File.Exists(legacyLangsPath))
|
||||
{
|
||||
langs = JsonConvert.DeserializeObject<Dictionary<string, string>>(File.ReadAllText(legacyLangsPath))
|
||||
?? new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
result.ImportedLangs = langs.Count;
|
||||
}
|
||||
|
||||
var adminIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (File.Exists(legacyAdminsPath))
|
||||
{
|
||||
var rawAdmins = JsonConvert.DeserializeObject<List<Dictionary<string, object>>>(File.ReadAllText(legacyAdminsPath))
|
||||
?? new List<Dictionary<string, object>>();
|
||||
|
||||
foreach (var row in rawAdmins)
|
||||
{
|
||||
if (row.TryGetValue("telegram_id", out var v) && v != null)
|
||||
adminIds.Add(v.ToString() ?? "");
|
||||
}
|
||||
result.ImportedAdmins = adminIds.Count;
|
||||
}
|
||||
|
||||
if (File.Exists(legacyTokensPath))
|
||||
{
|
||||
var raw = JsonConvert.DeserializeObject<List<LegacyTokenRecord>>(File.ReadAllText(legacyTokensPath))
|
||||
?? new List<LegacyTokenRecord>();
|
||||
|
||||
foreach (var item in raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.telegram_id))
|
||||
continue;
|
||||
|
||||
if (!users.TryGetValue(item.telegram_id, out var user))
|
||||
{
|
||||
var pending = LegacyApprovedByIsRegistrationPending(item.approved_by);
|
||||
var isAdmin = adminIds.Contains(item.telegram_id);
|
||||
if (isAdmin)
|
||||
pending = false;
|
||||
|
||||
var approvedBy = string.IsNullOrWhiteSpace(item.approved_by)
|
||||
? "legacy-import"
|
||||
: item.approved_by.Trim();
|
||||
|
||||
user = new TelegramUserRecord
|
||||
{
|
||||
TelegramId = item.telegram_id,
|
||||
TgUsername = item.tg_username ?? "",
|
||||
ApprovedBy = approvedBy,
|
||||
CreatedAt = item.created_at,
|
||||
ExpiresAt = item.expires_at,
|
||||
Lang = langs.TryGetValue(item.telegram_id, out var lang) ? lang : "ru",
|
||||
Role = isAdmin ? "admin" : "user",
|
||||
Disabled = pending,
|
||||
RegistrationPending = pending,
|
||||
Devices = new List<DeviceRecord>()
|
||||
};
|
||||
users[item.telegram_id] = user;
|
||||
}
|
||||
|
||||
foreach (var device in item.devices ?? new List<LegacyDeviceRecord>())
|
||||
{
|
||||
if (device == null || string.IsNullOrWhiteSpace(device.uid))
|
||||
continue;
|
||||
|
||||
if (user.Devices.Any(d => string.Equals(d.Uid, device.uid, StringComparison.OrdinalIgnoreCase)))
|
||||
continue;
|
||||
|
||||
DateTime linkedAt = item.created_at ?? DateTime.UtcNow;
|
||||
if (!string.IsNullOrWhiteSpace(device.bound_at))
|
||||
DateTime.TryParse(device.bound_at, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out linkedAt);
|
||||
|
||||
DateTime? lastSeenAt = null;
|
||||
if (!string.IsNullOrWhiteSpace(device.last_seen) && DateTime.TryParse(device.last_seen, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsedLastSeen))
|
||||
lastSeenAt = parsedLastSeen;
|
||||
|
||||
user.Devices.Add(new DeviceRecord
|
||||
{
|
||||
Uid = device.uid,
|
||||
Name = device.label,
|
||||
LinkedAt = linkedAt,
|
||||
LastSeenAt = lastSeenAt,
|
||||
Active = true,
|
||||
Source = "legacy-import"
|
||||
});
|
||||
result.ImportedDevices++;
|
||||
}
|
||||
}
|
||||
|
||||
result.ImportedUsers = users.Count;
|
||||
}
|
||||
|
||||
SaveUsers(users.Values.OrderBy(u => u.TelegramId, StringComparer.Ordinal).ToList());
|
||||
File.WriteAllText(langsPath, JsonConvert.SerializeObject(langs, Formatting.Indented));
|
||||
File.WriteAllText(adminsPath, JsonConvert.SerializeObject(adminIds.OrderBy(x => x, StringComparer.Ordinal).ToList(), Formatting.Indented));
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<OutputType>library</OutputType>
|
||||
<IsPackable>true</IsPackable>
|
||||
<RootNamespace>TelegramAuth</RootNamespace>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\Shared\Shared.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"TelegramAuth": {
|
||||
"enable": true,
|
||||
"data_dir": "database/tgauth",
|
||||
"legacy_import_path": "database/legacy_tgauth",
|
||||
"enable_import": true,
|
||||
"enable_cleanup": true,
|
||||
"mutations_api_secret": "GENERATE_A_LONG_RANDOM_SECRET",
|
||||
"owner_telegram_ids": [123456789],
|
||||
"auto_provision_users": false,
|
||||
"auto_provision_role": "user",
|
||||
"auto_provision_lang": "ru",
|
||||
"auto_provision_expires_days": 0,
|
||||
"auto_provision_activate_immediately": false,
|
||||
"accsdb_sync_group_admin": 100,
|
||||
"accsdb_sync_group_user": 0,
|
||||
"max_active_devices_per_user": 5,
|
||||
"limit_map": [
|
||||
{
|
||||
"pattern": "^/tg/auth",
|
||||
"map": {
|
||||
"limit": 25,
|
||||
"second": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"enable": true,
|
||||
"dynamic": true,
|
||||
"tree": [
|
||||
"Controllers",
|
||||
"Models",
|
||||
"Services",
|
||||
"ModInit.cs"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user