using System.Net; using System.Net.Http; using System.Net.Http.Json; using System.Threading; using System.Threading.Tasks; using Content.Shared.CCVar; using Content.Shared.DiscordAuth; using JetBrains.Annotations; using Robust.Server.Player; using Robust.Shared.Configuration; using Robust.Shared.Enums; using Robust.Shared.Network; using Robust.Shared.Player; namespace Content.Server.DiscordAuth; // TODO: Add minimal Discord account age check for panic bunker by extracting timestamp from snowflake received from API secured with key /// /// Manage Discord linking with SS14 account through external API /// public sealed class DiscordAuthManager { [Dependency] private readonly IServerNetManager _net = default!; [Dependency] private readonly IPlayerManager _player = default!; [Dependency] private readonly IConfigurationManager _configuration = default!; private ISawmill _sawmill = default!; private readonly HttpClient _httpClient = new(); private bool _isEnabled = false; private string _apiUrl = string.Empty; private string _apiKey = string.Empty; /// /// Raised when player passed verification or if feature disabled /// public event EventHandler? PlayerVerified; public void Initialize() { _sawmill = Logger.GetSawmill("discord_auth"); _configuration.OnValueChanged(CCVars.DiscordAuthEnabled, v => _isEnabled = v, true); _configuration.OnValueChanged(CCVars.DiscordAuthApiUrl, v => _apiUrl = v, true); _configuration.OnValueChanged(CCVars.DiscordAuthApiKey, v => _apiKey = v, true); _net.RegisterNetMessage(); _net.RegisterNetMessage(OnAuthCheck); _player.PlayerStatusChanged += OnPlayerStatusChanged; } private async void OnAuthCheck(DiscordAuthCheckMessage message) { var isVerified = await IsVerified(message.MsgChannel.UserId); if (isVerified) { var session = _player.GetSessionById(message.MsgChannel.UserId); PlayerVerified?.Invoke(this, session); } } private async void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs e) { if (e.NewStatus != SessionStatus.Connected) return; if (!_isEnabled) { PlayerVerified?.Invoke(this, e.Session); return; } if (e.NewStatus == SessionStatus.Connected) { var isVerified = await IsVerified(e.Session.UserId); if (isVerified) { PlayerVerified?.Invoke(this, e.Session); return; } var authUrl = await GenerateAuthLink(e.Session.UserId); var msg = new DiscordAuthRequiredMessage { AuthUrl = authUrl }; e.Session.Channel.SendMessage(msg); } } public async Task GenerateAuthLink(NetUserId userId, CancellationToken cancel = default) { _sawmill.Info($"Player {userId} requested generation Discord verification link"); var requestUrl = $"{_apiUrl}/{WebUtility.UrlEncode(userId.ToString())}?key={_apiKey}"; var response = await _httpClient.PostAsync(requestUrl, null, cancel); if (!response.IsSuccessStatusCode) { var content = await response.Content.ReadAsStringAsync(); throw new Exception($"Verification API returned bad status code: {response.StatusCode}\nResponse: {content}"); } var data = await response.Content.ReadFromJsonAsync(cancellationToken: cancel); return data!.Url; } public async Task IsVerified(NetUserId userId, CancellationToken cancel = default) { _sawmill.Debug($"Player {userId} check Discord verification"); var requestUrl = $"{_apiUrl}/{WebUtility.UrlEncode(userId.ToString())}"; var response = await _httpClient.GetAsync(requestUrl, cancel); if (!response.IsSuccessStatusCode) { var content = await response.Content.ReadAsStringAsync(); throw new Exception($"Verification API returned bad status code: {response.StatusCode}\nResponse: {content}"); } var data = await response.Content.ReadFromJsonAsync(cancellationToken: cancel); return data!.IsLinked; } [UsedImplicitly] private sealed record DiscordGenerateLinkResponse(string Url); [UsedImplicitly] private sealed record DiscordAuthInfoResponse(bool IsLinked); }