Files
Campus-25/server/Server/2025-RecRoom-Server-E12354-main/Controllers/AuthLauncherController.cs
T
2026-10-14 06:59:51 +02:00

306 lines
17 KiB
C#

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:<random port>, opens the browser at
// /api/auth/discord/login?state=launcher:<port>
// and expects the browser to be redirected to
// http://127.0.0.1:<port>/campus-auth?token=<jwt>
// 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:<port>" => (new) redirect browser to http://127.0.0.1:<port>/campus-auth?token=<jwt>
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=<X>&state=<Y>. `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:<port>" → redirect to http://127.0.0.1:<port>/campus-auth?token=<jwt>
// - 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<IActionResult> 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<string, string>
{
["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; } }
}
}