168 lines
8.4 KiB
C#
168 lines
8.4 KiB
C#
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 = "6JOoRs9-b-B8m8y5t9TnhURs6dOO-P-2";
|
|
internal const string RedirectUri = "https://gameauth.ddns.net/api/auth/discord/callback";
|
|
|
|
[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("/settings?discord=expired");
|
|
if (!ulong.TryParse(discordId, out ulong linkDid))
|
|
return Redirect("/settings?discord=error");
|
|
|
|
// One Discord may own only one Campus account.
|
|
ulong otherAcc = PlayerDB.FindPlayerByDiscordId(linkDid, linkAccId);
|
|
if (otherAcc != 0)
|
|
return Redirect("/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("/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.");
|
|
}
|
|
|
|
// ✅ 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 gameauth.ddns.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");
|
|
}
|
|
|
|
Console.WriteLine($"[DiscordAuth] Issued admin JWT for account_id={account.playerid}, redirecting to /admin/home/v4");
|
|
return Redirect("/admin/home/v4");
|
|
}
|
|
}
|
|
}
|