using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; using Campus_live_rewrite.api.dbs; using Campus_live_rewrite.api.server; using Campus_live_rewrite.api.server.Classes; using Campus_live_rewrite.api.server.Classes.db_class; using System.Net.Http.Headers; using System.Text.Json; namespace Campus_live_rewrite.Controllers { // Auth endpoints for the CampusPatch2025 Launcher. // // These live alongside the existing DiscordController (web-admin OAuth flow). The launcher // uses a different callback strategy than the web admin: it spins up a short-lived HTTP // listener on 127.0.0.1:, opens the browser at // /api/auth/discord/login?state=launcher: // and expects the browser to be redirected to // http://127.0.0.1:/campus-auth?token= // after Discord OAuth completes. That JWT is the launcher's session credential; the launcher // saves it locally and uses it as a Bearer for /api/auth/me + /api/auth/link/steam etc. // // Non-OAuth flows: launcher can also register with username+password (creates account with // no Steam yet) or log in with existing credentials. Same JWT is returned in all three cases. [ApiController] [Route("api/auth")] public class AuthLauncherController : ControllerBase { // Discord OAuth app is shared with DiscordController — same Client Id/Secret/Redirect. The // callback discriminates admin-panel vs launcher by inspecting the `state` param format: // "admin" => (default, existing) redirect to /admin/home/v4 // "launcher:" => (new) redirect browser to http://127.0.0.1:/campus-auth?token= private const string ClientId = "1537203658725265488"; private const string ClientSecret = "6JOoRs9-b-B8m8y5t9TnhURs6dOO-P-2"; private const string RedirectUri = "https://gameauth.ddns.net/api/auth/discord/login"; // ---- Discord OAuth — combined start + callback ------------------------------------------ // GET /api/auth/discord/login // Discord's registered redirect URI is now this same endpoint (see RedirectUri // constant above), so the browser makes TWO trips through here per login: // 1) Initial hit from the launcher's "Log in with Discord" button. `code` is // absent → we 302 the browser to Discord's OAuth authorize page. // 2) Discord redirects the user back with ?code=&state=. `code` is present // → we exchange the code for an access token, fetch the Discord user, find or // auto-create the linked Campus account, mint a JWT, and either: // - state == "launcher:" → redirect to http://127.0.0.1:/campus-auth?token= // - state == "admin" (default) → set cookie + redirect to /admin/home/v4 // Doing both phases in one route matches whatever redirect URI is on the Discord // dashboard without needing a separate /callback endpoint. [HttpGet("discord/login")] public async Task DiscordLoginLauncher([FromQuery] string? code = null, [FromQuery] string? state = null) { // Phase 1: no code yet → kick off OAuth by 302-ing to Discord. if (string.IsNullOrEmpty(code)) { string stateOut = string.IsNullOrEmpty(state) ? "admin" : state; var url = "https://discord.com/api/oauth2/authorize" + $"?client_id={ClientId}" + $"&redirect_uri={Uri.EscapeDataString(RedirectUri)}" + "&response_type=code" + "&scope=identify email" + $"&state={Uri.EscapeDataString(stateOut)}"; return Redirect(url); } // Phase 2: Discord redirected the user back here with a code. Exchange it. using var http = new HttpClient(); var tokenResponse = await http.PostAsync( "https://discord.com/api/oauth2/token", new FormUrlEncodedContent(new Dictionary { ["client_id"] = ClientId, ["client_secret"] = ClientSecret, ["grant_type"] = "authorization_code", ["code"] = code, ["redirect_uri"] = RedirectUri }) ); if (!tokenResponse.IsSuccessStatusCode) return Unauthorized("OAuth token exchange failed."); var tokenBody = await tokenResponse.Content.ReadAsStringAsync(); var accessTok = System.Text.Json.JsonDocument.Parse(tokenBody) .RootElement.GetProperty("access_token").GetString(); http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessTok); var userBody = await http.GetStringAsync("https://discord.com/api/users/@me"); var user = System.Text.Json.JsonDocument.Parse(userBody).RootElement; string discordIdStr = user.GetProperty("id").GetString(); string discordName = user.GetProperty("username").GetString(); ulong discordId = ulong.Parse(discordIdStr); // Find existing Campus account for this Discord id, or auto-create one. var accountIds = PlayerDB.GetAccountIdsByDiscordId(discordId); ulong accId; if (accountIds == null || !accountIds.Any()) { if (!PlayerDB.CreateAccount(Platforms.RecNet, 0UL, "", discordId, out var newAcc)) return StatusCode(500, "Failed to create Discord-linked account."); accId = newAcc.accountId; Console.WriteLine($"[DiscordAuth/launcher] Auto-created account_id={accId} for discord_id={discordId} ({discordName})"); } else { accId = accountIds.First(); Console.WriteLine($"[DiscordAuth/launcher] Existing account_id={accId} for discord_id={discordId} ({discordName})"); } string jwt = ClientSecurity.RefreshToken_Web(accId); // Launcher flow: bounce to the launcher's local http listener with the JWT. if (!string.IsNullOrEmpty(state) && state.StartsWith("launcher:", StringComparison.Ordinal)) { string portStr = state.Substring("launcher:".Length); if (int.TryParse(portStr, out int port) && port >= 1024 && port <= 65535) { string dest = $"http://127.0.0.1:{port}/campus-auth?token={Uri.EscapeDataString(jwt)}&account_id={accId}"; Console.WriteLine($"[DiscordAuth/launcher] redirecting to {dest}"); return Redirect(dest); } } // Admin panel flow (default): cookie + redirect to dashboard. Response.Cookies.Append("Authorization", jwt, new Microsoft.AspNetCore.Http.CookieOptions { Expires = DateTime.Now.AddHours(24), HttpOnly = true, Secure = true, SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax, Path = "/" }); return Redirect("/admin/home/v4"); } // ---- Register: username + password ------------------------------------------------------ // POST /api/auth/register {username, password} // Creates a PlayerDB account with no Steam / Discord link yet. Both are added later: // Steam via /api/auth/link/steam when the launcher extracts the local Steam ID, Discord // via /api/auth/link/discord if the user then chooses to Discord-link. Returns a JWT. [HttpPost("register")] public IActionResult Register([FromBody] AuthCreds body) { if (body == null || string.IsNullOrWhiteSpace(body.username) || string.IsNullOrWhiteSpace(body.password)) return BadRequest(new { success = false, error = "username and password required" }); // Reject if username already taken. The DB reserves username via PlayerDB.SetPlayerUsername // but there's no atomic create-with-username helper, so we check-then-create with a note // that racing two identical registers is possible (rare; second one gets rejected on next // rename check). var existing = PlayerDB.GetFullAccountByName(body.username); if (existing != null) return Conflict(new { success = false, error = "username already taken" }); // Create account with Platforms.RecNet (no Steam yet) and blank device id. Discord id 0 // means "not Discord-linked". Link steps happen separately. if (!PlayerDB.CreateAccount(Platforms.RecNet, 0UL, "", 0UL, out var newAcc)) return StatusCode(500, new { success = false, error = "account creation failed" }); PlayerDB.SetPlayerUsername(body.username, newAcc.accountId); PlayerDB.SetPlayerPassword(body.password, newAcc.accountId, log: true); string jwt = ClientSecurity.RefreshToken_Web(newAcc.accountId); return Ok(new { success = true, token = jwt, account_id = newAcc.accountId }); } // ---- Login: username + password --------------------------------------------------------- // POST /api/auth/login {username, password} // Delegates to PlayerDB.GetAccountPassword (same helper the web admin login uses). // Returns a JWT and a minimal account snapshot. Launcher usually calls /api/auth/me // right after for the fuller view including ban state, dev/mod flags, pfp, etc. [HttpPost("login")] public IActionResult Login([FromBody] AuthCreds body) { if (body == null || string.IsNullOrWhiteSpace(body.username) || string.IsNullOrWhiteSpace(body.password)) return BadRequest(new { success = false, error = "username and password required" }); var result = PlayerDB.GetAccountPassword(body.username, body.password, isAdminPanel: false); if (!result.success) return Unauthorized(new { success = false, error = result.error }); string jwt = ClientSecurity.RefreshToken_Web(result.accid); return Ok(new { success = true, token = jwt, account_id = result.accid }); } // ---- Logout ----------------------------------------------------------------------------- // POST /api/auth/logout // JWTs are stateless (HMACSHA256, no server-side session store), so logout is a no-op // server-side today. The launcher wipes its local JWT on 401 anyway. This endpoint just // returns 200 so the launcher has a well-known URL to POST to for symmetry. [HttpPost("logout")] public IActionResult Logout() => Ok(new { success = true }); // ---- Me --------------------------------------------------------------------------------- // GET /api/auth/me // Returns the full account snapshot the launcher UI needs: display name, pfp, discord + // steam ids, ban status + expiry, dev/mod flags. Launcher polls this at startup and on // every panel refresh. 401 tells the launcher to wipe its local JWT and show // "Login time expired, please log back in". [HttpGet("me")] public IActionResult Me() { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return Unauthorized(new { success = false, error = "invalid or expired token" }); var full = PlayerDB.GetFullAccount(account.Id); if (full == null) return NotFound(new { success = false, error = "account not found" }); // Ban info: HasActiveModerationBlock is the source of truth. Get_moderation_block // returns a JSON string of the current block; we parse it lightly to extract end time // + reason. If no active ban, everything stays null. bool banned = PlayerDB.HasActiveModerationBlock(account.Id); long? banEndsAt = null; string? banReason = null; if (banned) { try { var blockJson = PlayerDB.Get_moderation_block(account.Id); if (!string.IsNullOrEmpty(blockJson) && blockJson != "null") { var doc = System.Text.Json.JsonDocument.Parse(blockJson); var root = doc.RootElement; if (root.TryGetProperty("EndTime", out var e) && e.TryGetInt64(out var eSec)) banEndsAt = eSec; else if (root.TryGetProperty("ends_at", out var e2) && e2.TryGetInt64(out var e2Sec)) banEndsAt = e2Sec; if (root.TryGetProperty("Reason", out var r) && r.ValueKind == System.Text.Json.JsonValueKind.String) banReason = r.GetString(); else if (root.TryGetProperty("reason", out var r2) && r2.ValueKind == System.Text.Json.JsonValueKind.String) banReason = r2.GetString(); } } catch { /* leave ban fields null on parse error */ } } // Platform ids: pull first Steam / Oculus ids off PlatformIds list. string? steamId = null, metaId = null; if (full.player?.PlatformIds != null) { foreach (var p in full.player.PlatformIds) { if (p == null) continue; if (p.Platform == Platforms.Steam && steamId == null) steamId = p.PlatformId.ToString(); if (p.Platform == Platforms.Oculus && metaId == null) metaId = p.PlatformId.ToString(); } } return Ok(new { success = true, id = full.playerid, username = full.player?.Username, display_name = full.player?.DisplayName, profile_image = full.player?.profileImage, bio = full.player_Extra?.Bio, discord_id = full.player_Extra?.DiscordId == 0 ? (ulong?)null : full.player_Extra?.DiscordId, // Discord username isn't stored today. If it were captured during Discord OAuth // callback (and persisted onto player_Extra), it would surface here. For now null. discord_username = (string?)null, steam_id = steamId, meta_id = metaId, banned = banned, ban_ends_at = banEndsAt, ban_reason = banReason, is_dev = (full.dev_Flag & DevFlag.dev) == DevFlag.dev, is_mod = (full.dev_Flag & DevFlag.mod) == DevFlag.mod, }); } // ---- Link Steam -------------------------------------------------------------------------- // POST /api/auth/link/steam {steam_id} // Called by the launcher after Discord/register login once it extracts the local Steam // ID (via steam_api). Behaviour matrix: // - account has NO Steam yet AND no other account owns this steam_id → add it, 200 ok // - account has THIS steam_id already → 200 no-op // - account has a DIFFERENT steam_id (user changed Steam accounts) → swap to new one // - a DIFFERENT account owns this steam_id → 409 conflict, caller decides (probably // "This Steam account is linked to a different Campus account, log in there instead") [HttpPost("link/steam")] public IActionResult LinkSteam([FromBody] SteamLinkBody body) { var (account, error) = AuthHelper.ValidateToken(HttpContext); if (error != null) return Unauthorized(new { success = false, error = "invalid or expired token" }); if (body == null || body.steam_id == 0) return BadRequest(new { success = false, error = "steam_id required" }); // Check if the steam_id is already claimed by another account. PlayerDB.GetLogins(Platforms.Steam, body.steam_id, out var owners); if (owners != null) { foreach (var o in owners) { if (o.accountId != account.Id) return Conflict(new { success = false, error = "steam_id already linked to a different Campus account", other_account_id = o.accountId }); } } // Safe to add or swap. SetPlayerSteamId replaces the account's Steam entry (or adds if // missing) — see PlayerDB.SetPlayerSteamId. bool ok = PlayerDB.SetPlayerSteamId(body.steam_id, account.Id); return Ok(new { success = ok, steam_id = body.steam_id }); } // Body shapes. public class AuthCreds { public string? username { get; set; } public string? password { get; set; } } public class SteamLinkBody { public ulong steam_id { get; set; } } } }