the files
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Campus_live_rewrite.api.dbs;
|
||||
using Campus_live_rewrite.api.server;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Campus_live_rewrite.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
public class DiscordController : Controller
|
||||
{
|
||||
// ClientId + RedirectUri are shared with the website "Link Discord" flow (SiteController
|
||||
// builds the same authorize URL and reuses this SAME callback, branching on state="link:").
|
||||
// ClientSecret stays private - only the token exchange here needs it.
|
||||
internal const string ClientId = "1537203658725265488";
|
||||
private const string ClientSecret = "REDACTED";
|
||||
internal const string RedirectUri = "https://gameauth.ddns.net/api/auth/discord/callback"; // as registered with Discord - NOT the site host (camprec.net)
|
||||
|
||||
[HttpGet("discord")]
|
||||
public IActionResult DiscordLogin()
|
||||
{
|
||||
var url =
|
||||
"https://discord.com/api/oauth2/authorize" +
|
||||
$"?client_id={ClientId}" +
|
||||
$"&redirect_uri={Uri.EscapeDataString(RedirectUri)}" +
|
||||
"&response_type=code" +
|
||||
"&scope=identify email";
|
||||
|
||||
return Redirect(url);
|
||||
}
|
||||
|
||||
[HttpGet("discord/callback")]
|
||||
public async Task<IActionResult> DiscordCallback([FromQuery] string code, [FromQuery] string? state = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(code))
|
||||
return BadRequest("Missing OAuth code.");
|
||||
|
||||
using var http = new HttpClient();
|
||||
|
||||
// Exchange code for access token
|
||||
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 tokenJson = await tokenResponse.Content.ReadAsStringAsync();
|
||||
var token = JsonDocument.Parse(tokenJson)
|
||||
.RootElement.GetProperty("access_token").GetString();
|
||||
|
||||
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var userJson = await http.GetStringAsync("https://discord.com/api/users/@me");
|
||||
var user = JsonDocument.Parse(userJson).RootElement;
|
||||
|
||||
string discordId = user.GetProperty("id").GetString();
|
||||
string username = user.GetProperty("username").GetString();
|
||||
|
||||
// Email may be null
|
||||
string? email = null;
|
||||
if (user.TryGetProperty("email", out var emailProp))
|
||||
email = emailProp.GetString();
|
||||
|
||||
Console.WriteLine($"Discord Login: {username} ({discordId}) | Email: {email ?? "N/A"}");
|
||||
|
||||
// ---- Discord LINK flow (website Settings > Discord "Link Discord") --------------
|
||||
// state "link:<code>" means the user started this from Settings while signed in on the
|
||||
// website. This is COMPLETELY separate from the admin login below: resolve the code to
|
||||
// their Campus account, attach the Discord id, and send them back to /settings. It
|
||||
// NEVER issues an admin cookie, NEVER redirects to /admin, and NEVER auto-creates an
|
||||
// account - so linking Discord can't accidentally log anyone into the admin panel.
|
||||
if (!string.IsNullOrEmpty(state) && state.StartsWith("link:", StringComparison.Ordinal))
|
||||
{
|
||||
string linkCode = state.Substring("link:".Length);
|
||||
if (!DiscordLinkCodes.TryResolve(linkCode, out ulong linkAccId))
|
||||
return Redirect(ConfigCampus.SiteUrl + "/settings?discord=expired");
|
||||
if (!ulong.TryParse(discordId, out ulong linkDid))
|
||||
return Redirect(ConfigCampus.SiteUrl + "/settings?discord=error");
|
||||
|
||||
// One Discord may own only one Campus account.
|
||||
ulong otherAcc = PlayerDB.FindPlayerByDiscordId(linkDid, linkAccId);
|
||||
if (otherAcc != 0)
|
||||
return Redirect(ConfigCampus.SiteUrl + "/settings?discord=inuse");
|
||||
|
||||
PlayerDB.SetDiscordLink(linkAccId, linkDid, username);
|
||||
DiscordLinkCodes.Consume(linkCode);
|
||||
Console.WriteLine($"[DiscordLink] account {linkAccId} linked to discord {linkDid} ({username}) via OAuth");
|
||||
return Redirect(ConfigCampus.SiteUrl + "/settings?discord=linked");
|
||||
}
|
||||
|
||||
// 🔐 Lookup account (auto-create if this Discord id has never been seen)
|
||||
ulong discordIdUlong = ulong.Parse(discordId);
|
||||
var accountIds = PlayerDB.GetAccountIdsByDiscordId(discordIdUlong);
|
||||
|
||||
ulong resolvedAccId;
|
||||
if (accountIds == null || !accountIds.Any())
|
||||
{
|
||||
Console.WriteLine($"[DiscordAuth] No account linked to discord_id={discordIdUlong} ({username}) — auto-creating.");
|
||||
if (!PlayerDB.CreateAccount(Campus_live_rewrite.api.server.Classes.db_class.Platforms.RecNet, 0UL, "", discordIdUlong, out var newAcc))
|
||||
{
|
||||
Console.WriteLine($"[DiscordAuth] CreateAccount FAILED for discord_id={discordIdUlong}");
|
||||
return StatusCode(500, "Failed to create Discord-linked account.");
|
||||
}
|
||||
Console.WriteLine($"[DiscordAuth] Auto-created account_id={newAcc.accountId} for discord_id={discordIdUlong}");
|
||||
resolvedAccId = newAcc.accountId;
|
||||
}
|
||||
else
|
||||
{
|
||||
resolvedAccId = accountIds.First();
|
||||
Console.WriteLine($"[DiscordAuth] Existing account_id={resolvedAccId} for discord_id={discordIdUlong}");
|
||||
}
|
||||
|
||||
var account = PlayerDB.GetFullAccount(resolvedAccId);
|
||||
if (account is null)
|
||||
{
|
||||
Console.WriteLine($"[DiscordAuth] GetFullAccount returned null for accountId={resolvedAccId} (discord_id={discordIdUlong})");
|
||||
return Unauthorized("Account not found.");
|
||||
}
|
||||
|
||||
// The admin panel is 2FA-only (see AdminController's AdminLoginAPI) - Discord sign-in used to skip that
|
||||
// entirely and hand out a session on the spot, which made it a full bypass of the password-login gate.
|
||||
// Route it through the exact same ticket + step the password flow uses instead of minting a cookie here:
|
||||
// LoginV4.html already knows how to pick a `pending` ticket up from the query string and continue into
|
||||
// either the code step or forced-enrollment step, so this is just a different way to get a ticket there.
|
||||
// The admin ("Log in with Discord" button on LoginV4.html) flow sends NO `state` at all - only the
|
||||
// launcher's OAuth uses "launcher:<port>", and "link:" already returned above - so "not launcher" is
|
||||
// the correct test here, not "state present". Getting this backwards is exactly what would silently
|
||||
// reopen the bypass this whole block exists to close.
|
||||
bool isLauncherFlow = !string.IsNullOrEmpty(state) && state.StartsWith("launcher:", StringComparison.Ordinal);
|
||||
// Only a dev-or-mod-flagged account is admin-panel-eligible at all (same check AdminLoginAPI's
|
||||
// isAdminPanel password path applies via PlayerDB.CheckDevOrModFlag, now that mods can log into a
|
||||
// narrow slice of the panel too) - skip the 2FA detour for anyone else so a random Discord-linked
|
||||
// player isn't walked through mandatory enrollment just to get bounced by ValidateTokenDev(OrMod)
|
||||
// on the next page anyway. Mods must go through this exactly like devs do - CheckDevFlag alone here
|
||||
// would silently let a mod's Discord login skip 2FA entirely, reopening the bypass this closes.
|
||||
if (!isLauncherFlow && PlayerDB.CheckDevOrModFlag(account.playerid))
|
||||
{
|
||||
string step = TwoFactorDB.IsEnabled(account.playerid) ? "2fa" : "setup";
|
||||
string surface = step == "2fa" ? "admin" : "admin-setup";
|
||||
string pending = PendingTwoFactorLogin.Issue(account.playerid, surface);
|
||||
string loginHost = ConfigCampus.HostRouting && !string.Equals(Request.Host.Host, "localhost", StringComparison.OrdinalIgnoreCase) && Request.Host.Host != "127.0.0.1"
|
||||
? "https://" + Campus_live_rewrite.Middleware.HostRoutingMiddleware.AdminHost
|
||||
: "";
|
||||
Console.WriteLine($"[DiscordAuth] account_id={account.playerid} needs admin 2FA ({step}) - routing to login page with a pending ticket instead of a session");
|
||||
return Redirect($"{loginHost}/admin/v4/login?step={step}&pending={Uri.EscapeDataString(pending)}");
|
||||
}
|
||||
|
||||
// ✅ Issue JWT
|
||||
string jwtToken = ClientSecurity.RefreshToken_Web(account.playerid);
|
||||
|
||||
// SameSite=Lax (not Strict): a cookie set on a cross-site redirect from Discord is
|
||||
// still allowed AND is sent on the follow-up top-level GET (the /admin/home/v4
|
||||
// redirect). With Strict, the browser refuses to send it on that navigation, so the
|
||||
// admin page bounces you right back to the login form — which was the symptom.
|
||||
Response.Cookies.Append("Authorization", jwtToken, new Microsoft.AspNetCore.Http.CookieOptions
|
||||
{
|
||||
Expires = DateTime.Now.AddHours(24),
|
||||
HttpOnly = true,
|
||||
Secure = true,
|
||||
SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax,
|
||||
Path = "/"
|
||||
});
|
||||
|
||||
// Discriminate based on `state`. Web-admin login (default / "admin") stays on the
|
||||
// original path — cookie is set on camprec.net and we redirect to the admin
|
||||
// dashboard. Launcher OAuth (state="launcher:<port>") skips the cookie and instead
|
||||
// redirects the browser to the launcher's local http listener with the JWT in the
|
||||
// query string; the launcher picks it up there and closes the browser tab.
|
||||
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(jwtToken)}&account_id={account.playerid}";
|
||||
Console.WriteLine($"[DiscordAuth] launcher flow — redirecting to {dest}");
|
||||
return Redirect(dest);
|
||||
}
|
||||
Console.WriteLine($"[DiscordAuth] launcher flow — malformed port in state='{state}', falling through to admin redirect");
|
||||
}
|
||||
|
||||
// The panel lives on admin.camprec.net, but this callback lands on the address registered with Discord (gameauth.ddns.net) and a cookie
|
||||
// never crosses hosts: leave the token under a one-time ticket and let the panel's own host pick it up (/admin/discord-finish).
|
||||
if (ConfigCampus.HostRouting && !string.Equals(Request.Host.Host, "localhost", StringComparison.OrdinalIgnoreCase) && Request.Host.Host != "127.0.0.1")
|
||||
return Redirect("https://" + Campus_live_rewrite.Middleware.HostRoutingMiddleware.AdminHost + "/admin/discord-finish?t=" +
|
||||
Campus_live_rewrite.Middleware.AdminHandoff.Create(jwtToken));
|
||||
Console.WriteLine($"[DiscordAuth] Issued admin JWT for account_id={account.playerid}, redirecting to /admin/home/v4");
|
||||
return Redirect("/admin/home/v4");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user