:drunk: Write shit code trying to split up the Auth (WIP)

This commit is contained in:
2025-07-06 12:58:18 +08:00
parent 5757526ea5
commit 6a3d04af3d
224 changed files with 1889 additions and 36885 deletions

View File

@ -1,343 +0,0 @@
@page "/web/account/profile"
@model DysonNetwork.Sphere.Pages.Account.ProfileModel
@{
ViewData["Title"] = "Profile";
}
@if (Model.Account != null)
{
<div class="h-full bg-gray-100 dark:bg-gray-900 py-8 px-4">
<div class="max-w-6xl mx-auto">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Profile Settings</h1>
<p class="text-gray-600 dark:text-gray-400 mt-2">Manage your account information and preferences</p>
</div>
<!-- Two Column Layout -->
<div class="flex flex-col md:flex-row gap-8">
<!-- Left Pane - Profile Card -->
<div class="w-full md:w-1/3 lg:w-1/4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 sticky top-8">
<div class="flex flex-col items-center text-center">
<!-- Avatar -->
<div
class="w-32 h-32 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center mb-4 overflow-hidden">
<span class="text-4xl text-gray-500 dark:text-gray-400">
@Model.Account.Name?.Substring(0, 1).ToUpper()
</span>
</div>
<!-- Basic Info -->
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
@Model.Account.Nick
</h2>
<p class="text-gray-600 dark:text-gray-400">@Model.Account.Name</p>
<!-- Stats -->
<div
class="mt-4 flex justify-around w-full border-t border-gray-200 dark:border-gray-700 pt-4">
<div class="text-center">
<div
class="text-lg font-semibold text-gray-900 dark:text-white">@Model.Account.Profile.Level</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Level</div>
</div>
<div class="text-center">
<div
class="text-lg font-semibold text-gray-900 dark:text-white">@Model.Account.Profile.Experience</div>
<div class="text-sm text-gray-500 dark:text-gray-400">XP</div>
</div>
<div class="text-center">
<div
class="text-lg font-semibold text-gray-900 dark:text-white">
@Model.Account.CreatedAt.ToDateTimeUtc().ToString("yyyy/MM/dd")
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Member since</div>
</div>
</div>
</div>
</div>
</div>
<!-- Right Pane - Tabbed Content -->
<div class="flex-1">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
<!-- Tabs -->
<div class="border-b border-gray-200 dark:border-gray-700">
<nav class="flex -mb-px">
<button type="button"
class="tab-button active py-4 px-6 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200"
data-tab="profile">
Profile
</button>
<button type="button"
class="tab-button py-4 px-6 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200"
data-tab="security">
Security
</button>
<button type="button"
class="tab-button py-4 px-6 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200"
data-tab="sessions">
Sessions
</button>
</nav>
</div>
<!-- Tab Content -->
<div class="p-6">
<!-- Profile Tab -->
<div id="profile-tab" class="tab-content">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Profile
Information</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Basic
Information</h3>
<dl class="space-y-4">
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Full
Name
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
@($"{Model.Account.Profile.FirstName} {Model.Account.Profile.MiddleName} {Model.Account.Profile.LastName}".Trim())
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
Username
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">@Model.Account.Name</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
Nickname
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">@Model.Account.Nick</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
Gender
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">@Model.Account.Profile.Gender</dd>
</div>
</dl>
</div>
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Additional
Details</h3>
<dl class="space-y-4">
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
Location
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">@Model.Account.Profile.Location</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
Birthday
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
@Model.Account.Profile.Birthday?.ToString("MMMM d, yyyy", System.Globalization.CultureInfo.InvariantCulture)
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Bio
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
@(string.IsNullOrEmpty(Model.Account.Profile.Bio) ? "No bio provided" : Model.Account.Profile.Bio)
</dd>
</div>
</dl>
</div>
</div>
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<button type="button"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Edit Profile
</button>
</div>
</div>
<!-- Security Tab -->
<div id="security-tab" class="tab-content hidden">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Security
Settings</h2>
<div class="space-y-6">
<div class="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg">
<div class="px-4 py-5 sm:px-6">
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white">
Access Token</h3>
<p class="mt-1 max-w-2xl text-sm text-gray-500 dark:text-gray-400">Use this
token to authenticate with the API</p>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 px-4 py-5 sm:px-6">
<div class="flex items-center">
<input type="password" id="accessToken" value="@Model.AccessToken"
readonly
class="form-input flex-grow rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white py-2 px-4"/>
<button onclick="copyAccessToken()"
class="ml-4 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50">
Copy
</button>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Keep this token secure and do not share it with anyone.
</p>
</div>
</div>
</div>
</div>
<!-- Sessions Tab -->
<div id="sessions-tab" class="tab-content hidden">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Active
Sessions</h2>
<p class="text-gray-600 dark:text-gray-400 mb-6">This is a list of devices that have
logged into your account. Revoke any sessions that you do not recognize.</p>
<div class="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg">
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
<li class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div
class="flex-shrink-0 h-10 w-10 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center">
<svg class="h-6 w-6 text-blue-600 dark:text-blue-400"
fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M4 4a2 2 0 012-2h8a2 2 0 012 2v12a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 0v12h8V4H6z"
clip-rule="evenodd"/>
</svg>
</div>
<div class="ml-4">
<div class="text-sm font-medium text-gray-900 dark:text-white">
Current Session
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
@($"{Request.Headers["User-Agent"]} • {DateTime.Now:MMMM d, yyyy 'at' h:mm tt}")
</div>
</div>
</div>
<div class="ml-4 flex-shrink-0">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Active now
</span>
</div>
</div>
</li>
</ul>
<div class="bg-gray-50 dark:bg-gray-800 px-4 py-4 sm:px-6 text-right">
<button type="button"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
Sign out all other sessions
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Logout Button -->
<div class="mt-6 flex justify-end">
<form method="post" asp-page-handler="Logout">
<button type="submit"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
Sign out
</button>
</form>
</div>
</div>
</div>
</div>
</div>
}
else
{
<div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
<div class="max-w-md w-full p-8 bg-white dark:bg-gray-800 rounded-lg shadow-md text-center">
<div class="text-red-500 text-5xl mb-4">
<i class="fas fa-exclamation-circle"></i>
</div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">Profile Not Found</h2>
<p class="text-gray-600 dark:text-gray-400 mb-6">User profile not found. Please log in to continue.</p>
<a href="/auth/login"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Go to Login
</a>
</div>
</div>
}
@section Scripts {
<script>
// Tab functionality
document.addEventListener('DOMContentLoaded', function () {
// Get all tab buttons and content
const tabButtons = document.querySelectorAll('.tab-button');
const tabContents = document.querySelectorAll('.tab-content');
// Add click event listeners to tab buttons
tabButtons.forEach(button => {
button.addEventListener('click', function () {
const tabId = this.getAttribute('data-tab');
// Update active tab button
tabButtons.forEach(btn => btn.classList.remove('border-blue-500', 'text-blue-600', 'dark:text-blue-400'));
this.classList.add('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
// Show corresponding tab content
tabContents.forEach(content => {
content.classList.add('hidden');
if (content.id === `${tabId}-tab`) {
content.classList.remove('hidden');
}
});
});
});
// Show first tab by default
if (tabButtons.length > 0) {
tabButtons[0].click();
}
});
// Copy access token to clipboard
function copyAccessToken() {
const copyText = document.getElementById("accessToken");
copyText.select();
copyText.setSelectionRange(0, 99999);
document.execCommand("copy");
// Show tooltip or notification
const originalText = event.target.innerHTML;
event.target.innerHTML = '<i class="fas fa-check mr-1"></i> Copied!';
event.target.disabled = true;
setTimeout(() => {
event.target.innerHTML = originalText;
event.target.disabled = false;
}, 2000);
}
// Toggle password visibility
function togglePasswordVisibility(inputId) {
const input = document.getElementById(inputId);
const icon = document.querySelector(`[onclick="togglePasswordVisibility('${inputId}')"] i`);
if (input.type === 'password') {
input.type = 'text';
icon.classList.remove('fa-eye');
icon.classList.add('fa-eye-slash');
} else {
input.type = 'password';
icon.classList.remove('fa-eye-slash');
icon.classList.add('fa-eye');
}
}
</script>
}

View File

@ -1,28 +0,0 @@
using DysonNetwork.Sphere.Auth;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace DysonNetwork.Sphere.Pages.Account;
public class ProfileModel : PageModel
{
public DysonNetwork.Sphere.Account.Account? Account { get; set; }
public string? AccessToken { get; set; }
public Task<IActionResult> OnGetAsync()
{
if (HttpContext.Items["CurrentUser"] is not Sphere.Account.Account currentUser)
return Task.FromResult<IActionResult>(RedirectToPage("/Auth/Login"));
Account = currentUser;
AccessToken = Request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out var value) ? value : null;
return Task.FromResult<IActionResult>(Page());
}
public IActionResult OnPostLogout()
{
HttpContext.Response.Cookies.Delete(AuthConstants.CookieTokenName);
return RedirectToPage("/Auth/Login");
}
}

View File

@ -1,127 +0,0 @@
@page "/auth/authorize"
@model DysonNetwork.Sphere.Pages.Auth.AuthorizeModel
@{
ViewData["Title"] = "Authorize Application";
}
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 transition-colors duration-200 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 bg-white dark:bg-gray-800 p-8 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 transition-colors duration-200">
<div class="text-center">
<h2 class="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">
Authorize Application
</h2>
@if (!string.IsNullOrEmpty(Model.AppName))
{
<div class="mt-6">
<div class="flex items-center justify-center">
@if (!string.IsNullOrEmpty(Model.AppLogo))
{
<div class="relative h-16 w-16 flex-shrink-0">
<img class="h-16 w-16 rounded-lg object-cover border border-gray-200 dark:border-gray-700"
src="@Model.AppLogo"
alt="@Model.AppName logo"
onerror="this.onerror=null; this.src='data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iY3VycmVudENvbG9yIiBjbGFzczz0idy02IGgtNiB0ZXh0LWdyYXktNDAwIj48cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xOS42MDMgMy4wMDRjMS4xNTMgMCAyLjI2LjE4IDMuMzI5LjUxM2EuNzUuNzUgMCAwMS0uMzI4IDEuNDQ1IDE2LjkzIDE2LjkzIDAgMDAtNi4wMS0xuMTAzLjc1Ljc1IDAgMDEtLjY2OC0uNzQ4VjMuNzVBLjc1Ljc1IDAgMDExNi41IDNoMy4xMDN6TTMxLjUgMjEuMDA4Yy0uU0UuNzUgNzUuNzUgMCAwMTEuOTQ4LjA1MWMuMDgxLjkxOC4wNTIgMS44MzgtLjA4NiAyLjczNGEuNzUuNzUgMCAwMS0xLjQ5LS4xNTkgMjEuMzg2IDIxLjM4NiAwIDAwLjA2LTIuNTc1Ljc1Ljc1IDAgMDExLjU3OC0uMDQzem0tMy4wMi0xOC4wMmMuMDYgMS4wOTIuMDQgMi4xODctLjA1NiAzLjI3MmEuNzUuNzUgMCAwMS0xLjQ5Mi0uMTY0Yy4wOTItLjg3NC4xMDctMS43NTYuMDUxLTIuNjA3YS43NS43NSAwIDAxMS40OTctLjEwMXpNNS42MzcgNS42MzJjLS4zMzQuMS0uNjc2LjE4NS0xLjAyNi4yNTdhLjc1Ljc1IDAgMDEuMTQ5LTEuNDljLjQyNS0uMDg1Ljg1Mi0uMTg5IDEuMjY4LS4zMDRhLjc1Ljc1IDAgMDEuMzYgMS40Mzd6TTMuMzMgMTkuNjUzYy0uMTY1LS4zNS0uMzA4LS43MDctNDIuNjUzLjc1Ljc1IDAgMS4zODgtLjU0MiAxLjQ5LTEuMjg1LjA0Ni0uMzI2LjEwNi0uNjUyLjE4LS45NzZhLjc1Ljc1IDAgMTExLjQ2LS41M2MuMTA2LjQzNy4xODkuODgxLjI0NSAxLjM0OGEuNzUuNzUgMCAwMS0xLjQ5LjIzM3pNTEuMzUzIDIuNzY3YS43NS43NSAwIDAxLjc1LS4wMTdsLjAwMS4wMDFoLjAwMWwuMDAyLjAwMWwuMDA3LjAwM2wuMDI0LjAxM2MuMDIuMDEuMDQ1LjAyNS4wNzkuMDQ2LjA2Ny4wNDIuMTYxLjEwMi4yNzUuMTc4LjIzMi4xNTEuNTUuMzgzLjg1Ni42NjdsLjAyNy4wMjRjLjYxNi41NTggMS4yMTIgMS4xNzYgMS43MzMgMS44NDNhLjc1Ljc1IDAgMDEtMS4yNC44N2MtLjQ3LS42NzEtMS4wMzEtMS4yOTItMS42LFsxLjYxNi0xLjYxNmEzLjc1IDMuNzUgMCAwMC01LjMwNS01LjMwNGwtMS4wNi0xuMDZBNy4yNDMgNy4yNDMgMCAwMTUxLjM1MyAyLjc2N3pNNDQuMzc5IDkuNjRsLTEuNTYgMS41NmE2Ljk5IDYuOTkgMCAwMTIuMjMgNC4zMzcgNi45OSA2Ljk5IDAgMDEtMi4yMyA1LjE3NmwtMS41Ni0xLjU2QTguNDkgOC40OSAwIDAwNDUuNSAxNS41YzAtMi4yOTYtLjg3NC00LjQzLTIuMTIxLTYuMDF6bS0zLjUzLTMuNTNsLTEuMDYxLTEuMDZhNy4yNDMgNy4yNDMgMCAwMTkuMTkyIDkuE2x0LTEuMDYgMS4wNjFhNS43NDkgNS43NDkgMCAwMC04LjEzLTguMTN6TTM0LjUgMTUuNWE4Ljk5IDguOTkgMCAwMC0yLjYzMSA2LjM2OS43NS43NSAwIDExLTEuNDk0LS43MzlDNzIuMzkgMjAuMDYgMzMuNSAxNy41NzUgMzMuNSAxNS41YzAtMi4zNzYgMS4wOTktNC40MzggMi44MTEtNS44MTJsLS4zOTYtLjM5NmEuNzUuNzUgMCAwMTEuMDYtMS4wNkwzNy41IDkuNDRWMmgtL4wMDJhLjc1Ljc1IDAgMDEtLjc0OC0uNzVWMS41YS43NS43NSAwIDAxLjc1LS43NWg2YS43NS43NSAwIDAxLjc1Ljc1di4yNWEwIC43NS0uNzUuNzVoLS4wMDF2Ny40NGwzLjUzNy0zLjUzN2EuNzUuNzUgMCAwMTEuMDYgMS4wNmwtLjM5Ni4zOTZDMzUuNDAxIDExLjA2MiAzNC41IDEzLjEyNCAzNC41IDE1LjV6TTM5IDIuMjV2Ni4wMy0uMDAyYTEuNSAxLjUgMCAwMS0uNDQ0IDEuMDZsLTEuMDYxIDEuMDZBOC40OSA4LjQ5IDAgMDAzOSAxNS41YzAgMi4yOTYtLjg3NCA0LjQzLTIuMTIxIDYuMDFsMS41NiAxLjU2QTYuOTkgNi45OSAwIDAwNDIgMTUuNWE2Ljk5IDYuOTkgMCAwMC0yLjIzLTUuMTc2bC0xLjU2LTEuNTZhMS41IDEuNSAwIDAxLS40NC0xLjA2VjIuMjVoLTF6TTI0IDkuNzVhLjc1Ljc1IDAgMDEtLjc1Ljc1di0uNWMwLS40MTQuMzM2LS43NS43NS0uNzVoLjVjLjQxNCAwIC43NS4zMzYuNzUuNzV2LjVhLjc1Ljc1IDAgMDEtLjc1Ljc1aC0uNXpNMTkuNSAxM2EuNzUuNzUgMCAwMS0uNzUtLjc1di0uNWMwLS40MTQuMzM2LS43NS43NS0uNzVoLjVjLjQxNCAwIC43NS4zMzYuNzUuNzV2LjVhLjc1Ljc1IDAgMDEtLjc1Ljc1aC0uNXpNMTUgMTYuMjVhLjc1Ljc1IDAgMDEuNzUtLjc1aC41Yy40MTQgMCAuNzUuMzM2Ljc1Ljc1di41YS43NS43NSAwIDAxLS43NS43NWgtLjVhLjc1Ljc1IDAgMDEtLjc1LS43NXYtLjV6IiBjbGlwLXJ1bGU9ImV2ZW5vZGQiLz48L3N2Zz4=';">
<div class="absolute inset-0 flex items-center justify-center bg-gray-100 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600">
<span class="text-xs font-medium text-gray-500 dark:text-gray-300">@Model.AppName?[0]</span>
</div>
</div>
}
<div class="ml-4 text-left">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">@Model.AppName</h3>
@if (!string.IsNullOrEmpty(Model.AppUri))
{
<a href="@Model.AppUri" class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300 transition-colors duration-200" target="_blank" rel="noopener noreferrer">
@Model.AppUri
</a>
}
</div>
</div>
</div>
}
<p class="mt-6 text-sm text-gray-600 dark:text-gray-300">
wants to access your account with the following permissions:
</p>
</div>
<div class="mt-6">
<ul class="border border-gray-200 dark:border-gray-700 rounded-lg divide-y divide-gray-200 dark:divide-gray-700 overflow-hidden">
@if (Model.Scope != null)
{
var scopeDescriptions = new Dictionary<string, (string Name, string Description)>
{
["openid"] = ("OpenID", "Read your basic profile information"),
["profile"] = ("Profile", "View your basic profile information"),
["email"] = ("Email", "View your email address"),
["offline_access"] = ("Offline Access", "Access your data while you're not using the application")
};
foreach (var scope in Model.Scope.Split(' ').Where(s => !string.IsNullOrWhiteSpace(s)))
{
var scopeInfo = scopeDescriptions.GetValueOrDefault(scope, (scope, scope.Replace('_', ' ')));
<li class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150">
<div class="flex items-start">
<div class="flex-shrink-0 pt-0.5">
<svg class="h-5 w-5 text-green-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-900 dark:text-white">@scopeInfo.Item1</p>
<p class="text-xs text-gray-500 dark:text-gray-400">@scopeInfo.Item2</p>
</div>
</div>
</li>
}
}
</ul>
<div class="mt-4 text-xs text-gray-500 dark:text-gray-400">
<p>By authorizing, you allow this application to access your information on your behalf.</p>
</div>
</div>
<form method="post" class="mt-8 space-y-4">
<input type="hidden" asp-for="ClientIdString" />
<input type="hidden" asp-for="ResponseType" name="response_type" />
<input type="hidden" asp-for="RedirectUri" name="redirect_uri" />
<input type="hidden" asp-for="Scope" name="scope" />
<input type="hidden" asp-for="State" name="state" />
<input type="hidden" asp-for="Nonce" name="nonce" />
<input type="hidden" asp-for="ReturnUrl" name="returnUrl" />
<input type="hidden" name="code_challenge" value="@HttpContext.Request.Query["code_challenge"]" />
<input type="hidden" name="code_challenge_method" value="@HttpContext.Request.Query["code_challenge_method"]" />
<input type="hidden" name="response_mode" value="@HttpContext.Request.Query["response_mode"]" />
<div class="flex flex-col space-y-3">
<button type="submit" name="allow" value="true"
class="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-800 transition-colors duration-200">
Allow
</button>
<button type="submit" name="allow" value="false"
class="w-full flex justify-center py-2.5 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-800 transition-colors duration-200">
Deny
</button>
</div>
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<p class="text-xs text-center text-gray-500 dark:text-gray-400">
You can change these permissions later in your account settings.
</p>
</div>
</form>
</div>
</div>
@functions {
private string GetScopeDisplayName(string scope)
{
return scope switch
{
"openid" => "View your basic profile information",
"profile" => "View your profile information (name, picture, etc.)",
"email" => "View your email address",
"offline_access" => "Access your information while you're not using the app",
_ => scope
};
}
}

View File

@ -1,233 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using DysonNetwork.Sphere.Auth.OidcProvider.Services;
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Auth.OidcProvider.Responses;
using DysonNetwork.Sphere.Developer;
namespace DysonNetwork.Sphere.Pages.Auth;
public class AuthorizeModel(OidcProviderService oidcService, IConfiguration configuration) : PageModel
{
[BindProperty(SupportsGet = true)] public string? ReturnUrl { get; set; }
[BindProperty(SupportsGet = true, Name = "client_id")]
[Required(ErrorMessage = "The client_id parameter is required")]
public string? ClientIdString { get; set; }
public Guid ClientId { get; set; }
[BindProperty(SupportsGet = true, Name = "response_type")]
public string ResponseType { get; set; } = "code";
[BindProperty(SupportsGet = true, Name = "redirect_uri")]
public string? RedirectUri { get; set; }
[BindProperty(SupportsGet = true)] public string? Scope { get; set; }
[BindProperty(SupportsGet = true)] public string? State { get; set; }
[BindProperty(SupportsGet = true)] public string? Nonce { get; set; }
[BindProperty(SupportsGet = true, Name = "code_challenge")]
public string? CodeChallenge { get; set; }
[BindProperty(SupportsGet = true, Name = "code_challenge_method")]
public string? CodeChallengeMethod { get; set; }
[BindProperty(SupportsGet = true, Name = "response_mode")]
public string? ResponseMode { get; set; }
public string? AppName { get; set; }
public string? AppLogo { get; set; }
public string? AppUri { get; set; }
public string[]? RequestedScopes { get; set; }
public async Task<IActionResult> OnGetAsync()
{
// First check if user is authenticated
if (HttpContext.Items["CurrentUser"] is not Sphere.Account.Account currentUser)
{
var returnUrl = Uri.EscapeDataString($"{Request.Path}{Request.QueryString}");
return RedirectToPage("/Auth/Login", new { returnUrl });
}
// Validate client_id
if (string.IsNullOrEmpty(ClientIdString) || !Guid.TryParse(ClientIdString, out var clientId))
{
ModelState.AddModelError("client_id", "Invalid client_id format");
return BadRequest("Invalid client_id format");
}
ClientId = clientId;
// Get client info
var client = await oidcService.FindClientByIdAsync(ClientId);
if (client == null)
{
ModelState.AddModelError("client_id", "Client not found");
return NotFound("Client not found");
}
var config = client.OauthConfig;
if (config is null)
{
ModelState.AddModelError("client_id", "Client was not available for use OAuth / OIDC");
return BadRequest("Client was not enabled for OAuth / OIDC");
}
// Validate redirect URI for non-Developing apps
if (client.Status != CustomAppStatus.Developing)
{
if (!string.IsNullOrEmpty(RedirectUri) && !(config.RedirectUris?.Contains(RedirectUri) ?? false))
{
return BadRequest(new ErrorResponse
{
Error = "invalid_request",
ErrorDescription = "Invalid redirect_uri"
});
}
}
// Check for an existing valid session
var existingSession = await oidcService.FindValidSessionAsync(currentUser.Id, clientId);
if (existingSession != null)
{
// Auto-approve since valid session exists
return await HandleApproval(currentUser, client, existingSession);
}
// Show authorization page
var baseUrl = configuration["BaseUrl"];
AppName = client.Name;
AppLogo = client.Picture is not null ? $"{baseUrl}/files/{client.Picture.Id}" : null;
AppUri = config.ClientUri;
RequestedScopes = (Scope ?? "openid profile").Split(' ').Distinct().ToArray();
return Page();
}
private async Task<IActionResult> HandleApproval(Sphere.Account.Account currentUser, CustomApp client, Session? existingSession = null)
{
if (string.IsNullOrEmpty(RedirectUri))
{
ModelState.AddModelError("redirect_uri", "No redirect_uri provided");
return BadRequest("No redirect_uri provided");
}
string authCode;
if (existingSession != null)
{
// Reuse existing session
authCode = await oidcService.GenerateAuthorizationCodeForReuseSessionAsync(
session: existingSession,
clientId: ClientId,
redirectUri: RedirectUri,
scopes: Scope?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? [],
codeChallenge: CodeChallenge,
codeChallengeMethod: CodeChallengeMethod,
nonce: Nonce
);
}
else
{
// Create a new session (existing flow)
authCode = await oidcService.GenerateAuthorizationCodeAsync(
clientId: ClientId,
userId: currentUser.Id,
redirectUri: RedirectUri,
scopes: Scope?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? [],
codeChallenge: CodeChallenge,
codeChallengeMethod: CodeChallengeMethod,
nonce: Nonce
);
}
// Build the redirect URI with the authorization code
var redirectUriBuilder = new UriBuilder(RedirectUri);
var query = System.Web.HttpUtility.ParseQueryString(redirectUriBuilder.Query);
query["code"] = authCode;
if (!string.IsNullOrEmpty(State))
query["state"] = State;
if (!string.IsNullOrEmpty(Scope))
query["scope"] = Scope;
redirectUriBuilder.Query = query.ToString();
return Redirect(redirectUriBuilder.ToString());
}
public async Task<IActionResult> OnPostAsync(bool allow)
{
if (HttpContext.Items["CurrentUser"] is not Sphere.Account.Account currentUser) return Unauthorized();
// First, validate the client ID
if (string.IsNullOrEmpty(ClientIdString) || !Guid.TryParse(ClientIdString, out var clientId))
{
ModelState.AddModelError("client_id", "Invalid client_id format");
return BadRequest("Invalid client_id format");
}
ClientId = clientId;
// Check if a client exists
var client = await oidcService.FindClientByIdAsync(ClientId);
if (client == null)
{
ModelState.AddModelError("client_id", "Client not found");
return NotFound("Client not found");
}
if (!allow)
{
// User denied the authorization request
if (string.IsNullOrEmpty(RedirectUri))
return BadRequest("No redirect_uri provided");
var deniedUriBuilder = new UriBuilder(RedirectUri);
var deniedQuery = System.Web.HttpUtility.ParseQueryString(deniedUriBuilder.Query);
deniedQuery["error"] = "access_denied";
deniedQuery["error_description"] = "The user denied the authorization request";
if (!string.IsNullOrEmpty(State)) deniedQuery["state"] = State;
deniedUriBuilder.Query = deniedQuery.ToString();
return Redirect(deniedUriBuilder.ToString());
}
// User approved the request
if (string.IsNullOrEmpty(RedirectUri))
{
ModelState.AddModelError("redirect_uri", "No redirect_uri provided");
return BadRequest("No redirect_uri provided");
}
// Generate authorization code
var authCode = await oidcService.GenerateAuthorizationCodeAsync(
clientId: ClientId,
userId: currentUser.Id,
redirectUri: RedirectUri,
scopes: Scope?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>(),
codeChallenge: CodeChallenge,
codeChallengeMethod: CodeChallengeMethod,
nonce: Nonce);
// Build the redirect URI with the authorization code
var redirectUri = new UriBuilder(RedirectUri);
var query = System.Web.HttpUtility.ParseQueryString(redirectUri.Query);
// Add the authorization code
query["code"] = authCode;
// Add state if provided (for CSRF protection)
if (!string.IsNullOrEmpty(State))
query["state"] = State;
// Set the query string
redirectUri.Query = query.ToString();
// Redirect back to the client with the authorization code
return Redirect(redirectUri.ToString());
}
}

View File

@ -1,47 +0,0 @@
@page "/auth/callback"
@model DysonNetwork.Sphere.Pages.Auth.TokenModel
@{
ViewData["Title"] = "Authentication Successful";
Layout = "_Layout";
}
<div class="h-full flex items-center justify-center">
<div class="max-w-lg w-full mx-auto p-6 text-center">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Authentication Successful</h1>
<p class="mb-6 text-gray-900 dark:text-white">You can now close this window and return to the application.</p>
</div>
</div>
@section Scripts {
<script>
(function () {
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('challenge');
console.log("Authentication token received.");
// For WebView2/UWP apps that can handle window.external.notify
if (window.external && typeof window.external.notify === 'function') {
try {
if (!token)
window.external.notify('done');
else
window.external.notify(token);
console.log("Token sent via window.external.notify.");
return; // Exit after successful notification
} catch (e) {
console.error("Failed to send token via window.external.notify:", e);
}
}
// For mobile apps that use custom URI schemes
try {
const customSchemeUrl = `solian://auth/callback?challenge=${encodeURIComponent(token ?? 'done')}`;
window.location.href = customSchemeUrl;
console.log("Attempting to redirect to custom scheme:", customSchemeUrl);
} catch (e) {
console.error("Failed to redirect to custom scheme:", e);
}
})();
</script>
}

View File

@ -1,11 +0,0 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace DysonNetwork.Sphere.Pages.Auth
{
public class TokenModel : PageModel
{
public void OnGet()
{
}
}
}

View File

@ -1,13 +0,0 @@
@page "/web/auth/challenge/{id:guid}"
@model DysonNetwork.Sphere.Pages.Auth.ChallengeModel
@{
// This page is kept for backward compatibility
// It will automatically redirect to the new SelectFactor page
Response.Redirect($"/web/auth/challenge/{Model.Id}/select-factor");
}
<div class="h-full flex items-center justify-center bg-gray-100 dark:bg-gray-900">
<div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-md text-center">
<p>Redirecting to authentication page...</p>
</div>
</div>

View File

@ -1,19 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace DysonNetwork.Sphere.Pages.Auth
{
public class ChallengeModel() : PageModel
{
[BindProperty(SupportsGet = true)]
public Guid Id { get; set; }
[BindProperty(SupportsGet = true)]
public string? ReturnUrl { get; set; }
public IActionResult OnGet()
{
return RedirectToPage("SelectFactor", new { id = Id, returnUrl = ReturnUrl });
}
}
}

View File

@ -1,40 +0,0 @@
@page "/web/auth/login"
@model DysonNetwork.Sphere.Pages.Auth.LoginModel
@{
ViewData["Title"] = "Login";
var returnUrl = Model.ReturnUrl ?? "";
}
<div class="h-full flex items-center justify-center bg-gray-100 dark:bg-gray-900">
<div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-md">
<h1 class="text-2xl font-bold text-center text-gray-900 dark:text-white mb-6">Login</h1>
<form method="post">
<input type="hidden" asp-for="ReturnUrl" value="@returnUrl" />
<div class="mb-4">
<label asp-for="Username"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"></label>
<input asp-for="Username"
class="form-input mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white px-4 py-2"/>
<span asp-validation-for="Username" class="text-red-500 text-sm mt-1"></span>
</div>
<button type="submit"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50">
Next
</button>
</form>
<div class="mt-8 flex flex-col text-sm text-center">
<span class="text-gray-900 dark:text-white opacity-80">Have no account?</span>
<a href="https://solian.app/#/auth/create-account"
class="text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300">
Create a new account →
</a>
</div>
</div>
</div>
@section Scripts {
@{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); }
}

View File

@ -1,93 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Connection;
using NodaTime;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Pages.Auth
{
public class LoginModel(
AppDatabase db,
AccountService accounts,
AuthService auth,
GeoIpService geo,
ActionLogService als
) : PageModel
{
[BindProperty] [Required] public string Username { get; set; } = string.Empty;
[BindProperty]
[FromQuery]
public string? ReturnUrl { get; set; }
public void OnGet()
{
}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
return Page();
}
var account = await accounts.LookupAccount(Username);
if (account is null)
{
ModelState.AddModelError(string.Empty, "Account was not found.");
return Page();
}
// Store the return URL in TempData to preserve it during the login flow
if (!string.IsNullOrEmpty(ReturnUrl) && Url.IsLocalUrl(ReturnUrl))
{
TempData["ReturnUrl"] = ReturnUrl;
}
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
var existingChallenge = await db.AuthChallenges
.Where(e => e.Account == account)
.Where(e => e.IpAddress == ipAddress)
.Where(e => e.UserAgent == userAgent)
.Where(e => e.StepRemain > 0)
.Where(e => e.ExpiredAt != null && now < e.ExpiredAt)
.FirstOrDefaultAsync();
if (existingChallenge is not null)
{
return RedirectToPage("Challenge", new { id = existingChallenge.Id });
}
var challenge = new Challenge
{
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)),
StepTotal = await auth.DetectChallengeRisk(Request, account),
Platform = ChallengePlatform.Web,
Audiences = new List<string>(),
Scopes = new List<string>(),
IpAddress = ipAddress,
UserAgent = userAgent,
Location = geo.GetPointFromIp(ipAddress),
DeviceId = "web-browser",
AccountId = account.Id
}.Normalize();
await db.AuthChallenges.AddAsync(challenge);
await db.SaveChangesAsync();
// If we have a return URL, pass it to the verify page
if (TempData.TryGetValue("ReturnUrl", out var returnUrl) && returnUrl is string url)
{
return RedirectToPage("SelectFactor", new { id = challenge.Id, returnUrl = url });
}
return RedirectToPage("SelectFactor", new { id = challenge.Id });
}
}
}

View File

@ -1,81 +0,0 @@
@page "/web/auth/challenge/{id:guid}/select-factor"
@using DysonNetwork.Sphere.Account
@model DysonNetwork.Sphere.Pages.Auth.SelectFactorModel
@{
ViewData["Title"] = "Select Authentication Method";
}
<div class="h-full flex items-center justify-center bg-gray-100 dark:bg-gray-900">
<div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-md">
<h1 class="text-2xl font-bold text-center text-gray-900 dark:text-white mb-6">Select Authentication Method</h1>
@if (Model.AuthChallenge == null)
{
<p class="text-red-500 text-center">Challenge not found or expired.</p>
}
else if (Model.AuthChallenge.StepRemain == 0)
{
<p class="text-green-600 dark:text-green-400 text-center">Challenge completed. Redirecting...</p>
}
else
{
<p class="text-gray-700 dark:text-gray-300 mb-4">Please select an authentication method:</p>
<div class="space-y-4">
@foreach (var factor in Model.AuthFactors)
{
<div class="mb-4">
<form method="post" asp-page-handler="SelectFactor" class="w-full" id="factor-@factor.Id">
<input type="hidden" name="SelectedFactorId" value="@factor.Id"/>
@if (factor.Type == AccountAuthFactorType.EmailCode)
{
<div class="mb-3">
<label for="hint-@factor.Id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email to send code to
</label>
<input type="email"
id="hint-@factor.Id"
name="hint"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="Enter your email"
required>
</div>
}
<button type="submit"
class="w-full text-left p-4 bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors">
<div class="font-medium text-gray-900 dark:text-white">@GetFactorDisplayName(factor.Type)</div>
<div class="text-sm text-gray-500 dark:text-gray-400">@GetFactorDescription(factor.Type)</div>
</button>
</form>
</div>
}
</div>
}
</div>
</div>
@functions {
private string GetFactorDisplayName(AccountAuthFactorType type) => type switch
{
AccountAuthFactorType.InAppCode => "Authenticator App",
AccountAuthFactorType.EmailCode => "Email",
AccountAuthFactorType.TimedCode => "Timed Code",
AccountAuthFactorType.PinCode => "PIN Code",
AccountAuthFactorType.Password => "Password",
_ => type.ToString()
};
private string GetFactorDescription(AccountAuthFactorType type) => type switch
{
AccountAuthFactorType.InAppCode => "Enter a code from your authenticator app",
AccountAuthFactorType.EmailCode => "Receive a verification code via email",
AccountAuthFactorType.TimedCode => "Use a time-based verification code",
AccountAuthFactorType.PinCode => "Enter your PIN code",
AccountAuthFactorType.Password => "Enter your password",
_ => string.Empty
};
}

View File

@ -1,103 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Account;
namespace DysonNetwork.Sphere.Pages.Auth;
public class SelectFactorModel(
AppDatabase db,
AccountService accounts
)
: PageModel
{
[BindProperty(SupportsGet = true)] public Guid Id { get; set; }
[BindProperty(SupportsGet = true)] public string? ReturnUrl { get; set; }
[BindProperty] public Guid SelectedFactorId { get; set; }
[BindProperty] public string? Hint { get; set; }
public Challenge? AuthChallenge { get; set; }
public List<AccountAuthFactor> AuthFactors { get; set; } = [];
public async Task<IActionResult> OnGetAsync()
{
await LoadChallengeAndFactors();
if (AuthChallenge == null) return NotFound();
if (AuthChallenge.StepRemain == 0) return await ExchangeTokenAndRedirect();
return Page();
}
public async Task<IActionResult> OnPostSelectFactorAsync()
{
var challenge = await db.AuthChallenges
.Include(e => e.Account)
.FirstOrDefaultAsync(e => e.Id == Id);
if (challenge == null) return NotFound();
var factor = await db.AccountAuthFactors.FindAsync(SelectedFactorId);
if (factor?.EnabledAt == null || factor.Trustworthy <= 0)
return BadRequest("Invalid authentication method.");
// Store return URL in TempData to pass to the next step
if (!string.IsNullOrEmpty(ReturnUrl))
{
TempData["ReturnUrl"] = ReturnUrl;
}
// For OTP factors that require code delivery
try
{
// For OTP factors that require code delivery
if (
factor.Type == AccountAuthFactorType.EmailCode
&& string.IsNullOrWhiteSpace(Hint)
)
{
ModelState.AddModelError(string.Empty,
$"Please provide a {factor.Type.ToString().ToLower().Replace("code", "")} to send the code to."
);
await LoadChallengeAndFactors();
return Page();
}
await accounts.SendFactorCode(challenge.Account, factor, Hint);
}
catch (Exception ex)
{
ModelState.AddModelError(string.Empty,
$"An error occurred while sending the verification code: {ex.Message}");
await LoadChallengeAndFactors();
return Page();
}
// Redirect to verify page with return URL if available
return !string.IsNullOrEmpty(ReturnUrl)
? RedirectToPage("VerifyFactor", new { id = Id, factorId = factor.Id, returnUrl = ReturnUrl })
: RedirectToPage("VerifyFactor", new { id = Id, factorId = factor.Id });
}
private async Task LoadChallengeAndFactors()
{
AuthChallenge = await db.AuthChallenges
.Include(e => e.Account)
.FirstOrDefaultAsync(e => e.Id == Id);
if (AuthChallenge != null)
{
AuthFactors = await db.AccountAuthFactors
.Where(e => e.AccountId == AuthChallenge.Account.Id)
.Where(e => e.EnabledAt != null && e.Trustworthy >= 1)
.ToListAsync();
}
}
private async Task<IActionResult> ExchangeTokenAndRedirect()
{
// This method is kept for backward compatibility
// The actual token exchange is now handled in the VerifyFactor page
await Task.CompletedTask; // Add this to fix the async warning
return RedirectToPage("/Account/Profile");
}
}

View File

@ -1,77 +0,0 @@
@page "/web/auth/challenge/{id:guid}/verify/{factorId:guid}"
@using DysonNetwork.Sphere.Account
@model DysonNetwork.Sphere.Pages.Auth.VerifyFactorModel
@{
ViewData["Title"] = "Verify Your Identity";
}
<div class="h-full flex items-center justify-center bg-gray-100 dark:bg-gray-900">
<div class="bg-white dark:bg-gray-800 px-8 pt-8 pb-4 rounded-lg shadow-md w-full max-w-md">
<h1 class="text-2xl font-bold text-center text-gray-900 dark:text-white mb-2">Verify Your Identity</h1>
<p class="text-center text-gray-600 dark:text-gray-300 mb-6">
@switch (Model.FactorType)
{
case AccountAuthFactorType.EmailCode:
<span>We've sent a verification code to your email.</span>
break;
case AccountAuthFactorType.InAppCode:
<span>Enter the code from your authenticator app.</span>
break;
case AccountAuthFactorType.TimedCode:
<span>Enter your time-based verification code.</span>
break;
case AccountAuthFactorType.PinCode:
<span>Enter your PIN code.</span>
break;
case AccountAuthFactorType.Password:
<span>Enter your password.</span>
break;
default:
<span>Please verify your identity.</span>
break;
}
</p>
@if (Model.AuthChallenge == null)
{
<p class="text-red-500 text-center">Challenge not found or expired.</p>
}
else if (Model.AuthChallenge.StepRemain == 0)
{
<p class="text-green-600 dark:text-green-400 text-center">Verification successful. Redirecting...</p>
}
else
{
<form method="post" class="space-y-4">
<div asp-validation-summary="ModelOnly" class="text-red-500 text-sm"></div>
<div class="mb-4">
<label asp-for="Code" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
@(Model.FactorType == AccountAuthFactorType.Password ? "Use your password" : "Verification Code")
</label>
<input asp-for="Code"
class="form-input mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-white px-4 py-2"
autocomplete="one-time-code"
type="password"
autofocus />
<span asp-validation-for="Code" class="text-red-500 text-sm mt-1"></span>
</div>
<button type="submit"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50">
Verify
</button>
<div class="text-center mt-4">
<a asp-page="SelectFactor" asp-route-id="@Model.Id" class="text-sm text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300">
← Back to authentication methods
</a>
</div>
</form>
}
</div>
</div>
@section Scripts {
@{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); }
}

View File

@ -1,184 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Account;
using NodaTime;
namespace DysonNetwork.Sphere.Pages.Auth
{
public class VerifyFactorModel(
AppDatabase db,
AccountService accounts,
AuthService auth,
ActionLogService als,
IConfiguration configuration,
IHttpClientFactory httpClientFactory
)
: PageModel
{
[BindProperty(SupportsGet = true)] public Guid Id { get; set; }
[BindProperty(SupportsGet = true)] public Guid FactorId { get; set; }
[BindProperty(SupportsGet = true)] public string? ReturnUrl { get; set; }
[BindProperty, Required] public string Code { get; set; } = string.Empty;
public Challenge? AuthChallenge { get; set; }
public AccountAuthFactor? Factor { get; set; }
public AccountAuthFactorType FactorType => Factor?.Type ?? AccountAuthFactorType.EmailCode;
public async Task<IActionResult> OnGetAsync()
{
await LoadChallengeAndFactor();
if (AuthChallenge == null) return NotFound("Challenge not found or expired.");
if (Factor == null) return NotFound("Authentication method not found.");
if (AuthChallenge.StepRemain == 0) return await ExchangeTokenAndRedirect();
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
await LoadChallengeAndFactor();
return Page();
}
await LoadChallengeAndFactor();
if (AuthChallenge == null) return NotFound("Challenge not found or expired.");
if (Factor == null) return NotFound("Authentication method not found.");
try
{
if (await accounts.VerifyFactorCode(Factor, Code))
{
AuthChallenge.StepRemain -= Factor.Trustworthy;
AuthChallenge.StepRemain = Math.Max(0, AuthChallenge.StepRemain);
AuthChallenge.BlacklistFactors.Add(Factor.Id);
db.Update(AuthChallenge);
als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess,
new Dictionary<string, object>
{
{ "challenge_id", AuthChallenge.Id },
{ "factor_id", Factor?.Id.ToString() ?? string.Empty }
}, Request, AuthChallenge.Account);
await db.SaveChangesAsync();
if (AuthChallenge.StepRemain == 0)
{
als.CreateActionLogFromRequest(ActionLogType.NewLogin,
new Dictionary<string, object>
{
{ "challenge_id", AuthChallenge.Id },
{ "account_id", AuthChallenge.AccountId }
}, Request, AuthChallenge.Account);
return await ExchangeTokenAndRedirect();
}
else
{
// If more steps are needed, redirect back to select factor
return RedirectToPage("SelectFactor", new { id = Id, returnUrl = ReturnUrl });
}
}
else
{
throw new InvalidOperationException("Invalid verification code.");
}
}
catch (Exception ex)
{
if (AuthChallenge != null)
{
AuthChallenge.FailedAttempts++;
db.Update(AuthChallenge);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure,
new Dictionary<string, object>
{
{ "challenge_id", AuthChallenge.Id },
{ "factor_id", Factor?.Id.ToString() ?? string.Empty }
}, Request, AuthChallenge.Account);
}
ModelState.AddModelError(string.Empty, ex.Message);
return Page();
}
}
private async Task LoadChallengeAndFactor()
{
AuthChallenge = await db.AuthChallenges
.Include(e => e.Account)
.FirstOrDefaultAsync(e => e.Id == Id);
if (AuthChallenge?.Account != null)
{
Factor = await db.AccountAuthFactors
.FirstOrDefaultAsync(e => e.Id == FactorId &&
e.AccountId == AuthChallenge.Account.Id &&
e.EnabledAt != null &&
e.Trustworthy > 0);
}
}
private async Task<IActionResult> ExchangeTokenAndRedirect()
{
var challenge = await db.AuthChallenges
.Include(e => e.Account)
.FirstOrDefaultAsync(e => e.Id == Id);
if (challenge == null) return BadRequest("Authorization code not found or expired.");
if (challenge.StepRemain != 0) return BadRequest("Challenge not yet completed.");
var session = await db.AuthSessions
.FirstOrDefaultAsync(e => e.ChallengeId == challenge.Id);
if (session == null)
{
session = new Session
{
LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow),
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)),
Account = challenge.Account,
Challenge = challenge,
};
db.AuthSessions.Add(session);
await db.SaveChangesAsync();
}
var token = auth.CreateToken(session);
Response.Cookies.Append(AuthConstants.CookieTokenName, token, new CookieOptions
{
HttpOnly = true,
Secure = !configuration.GetValue<bool>("Debug"),
SameSite = SameSiteMode.Strict,
Path = "/"
});
// Redirect to the return URL if provided and valid, otherwise to the home page
if (!string.IsNullOrEmpty(ReturnUrl) && Url.IsLocalUrl(ReturnUrl))
{
return Redirect(ReturnUrl);
}
// Check TempData for return URL (in case it was passed through multiple steps)
if (TempData.TryGetValue("ReturnUrl", out var tempReturnUrl) && tempReturnUrl is string returnUrl &&
!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl))
{
return Redirect(returnUrl);
}
return RedirectToPage("/Index");
}
}
}

View File

@ -1,110 +0,0 @@
@page "/auth/captcha"
@model DysonNetwork.Sphere.Pages.Checkpoint.CheckpointPage
@{
ViewData["Title"] = "Security Checkpoint";
var cfg = ViewData.Model.Configuration;
var provider = cfg.GetSection("Captcha")["Provider"]?.ToLower();
var apiKey = cfg.GetSection("Captcha")["ApiKey"];
}
@section Scripts {
@switch (provider)
{
case "recaptcha":
<script src="https://www.recaptcha.net/recaptcha/api.js" async defer></script>
break;
case "cloudflare":
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
break;
case "hcaptcha":
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
break;
}
<script>
function getQueryParam(name) {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get(name);
}
function onSuccess(token) {
window.parent.postMessage("captcha_tk=" + token, "*");
const redirectUri = getQueryParam("redirect_uri");
if (redirectUri) {
window.location.href = `${redirectUri}?captcha_tk=${encodeURIComponent(token)}`;
}
}
</script>
}
<div class="h-full flex items-center justify-center">
<div class="max-w-lg w-full mx-auto p-6">
<div class="text-center">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Security Check</h1>
<p class="mb-6 text-gray-900 dark:text-white">Please complete the contest below to confirm you're not a robot</p>
<div class="flex justify-center mb-8">
@switch (provider)
{
case "cloudflare":
<div class="cf-turnstile"
data-sitekey="@apiKey"
data-callback="onSuccess">
</div>
break;
case "recaptcha":
<div class="g-recaptcha"
data-sitekey="@apiKey"
data-callback="onSuccess">
</div>
break;
case "hcaptcha":
<div class="h-captcha"
data-sitekey="@apiKey"
data-callback="onSuccess">
</div>
break;
default:
<div class="p-4 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
<p class="text-yellow-800 dark:text-yellow-200">
Captcha provider not configured correctly.
</p>
</div>
break;
}
</div>
</div>
<div class="mt-8 text-center text-sm">
<div class="font-semibold text-gray-700 dark:text-gray-300 mb-1">Solar Network Anti-Robot</div>
<div class="text-gray-600 dark:text-gray-400">
Powered by
@switch (provider)
{
case "cloudflare":
<a href="https://www.cloudflare.com/turnstile/"
class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
Cloudflare Turnstile
</a>
break;
case "recaptcha":
<a href="https://www.google.com/recaptcha/"
class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
Google reCaptcha
</a>
break;
default:
<span>Nothing</span>
break;
}
<br/>
Hosted by
<a href="https://github.com/Solsynth/DysonNetwork"
class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
DysonNetwork.Sphere
</a>
</div>
</div>
</div>
</div>

View File

@ -1,14 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace DysonNetwork.Sphere.Pages.Checkpoint;
public class CheckpointPage(IConfiguration configuration) : PageModel
{
[BindProperty] public IConfiguration Configuration { get; set; } = configuration;
public ActionResult OnGet()
{
return Page();
}
}

View File

@ -1,42 +0,0 @@
@using DysonNetwork.Sphere.Localization
@using Microsoft.Extensions.Localization
<EmailLayout>
<tr>
<td class="wrapper">
<p class="font-bold">@(Localizer["AccountDeletionHeader"])</p>
<p>@(Localizer["AccountDeletionPara1"]) @@@Name,</p>
<p>@(Localizer["AccountDeletionPara2"])</p>
<p>@(Localizer["AccountDeletionPara3"])</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="left">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td>
<a href="@Link" target="_blank">
@(Localizer["AccountDeletionButton"])
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p>@(Localizer["AccountDeletionPara4"])</p>
</td>
</tr>
</EmailLayout>
@code {
[Parameter] public required string Name { get; set; }
[Parameter] public required string Link { get; set; }
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
}

View File

@ -24,7 +24,7 @@
}
else
{
<a href="/web/auth/login" class="text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300 px-3 py-2 rounded-md text-sm font-medium">Login</a>
<a href="/pass/auth/login" class="text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300 px-3 py-2 rounded-md text-sm font-medium">Login</a>
}
</div>
</div>

View File

@ -1,5 +1,4 @@
@page "/spells/{spellWord}"
@using DysonNetwork.Sphere.Account
@model DysonNetwork.Sphere.Pages.Spell.MagicSpellPage
@{
@ -93,7 +92,7 @@
Powered by
<a href="https://github.com/Solsynth/DysonNetwork"
class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
DysonNetwork.Sphere
DysonNetwork.Pass
</a>
</div>
</div>

View File

@ -1,4 +1,3 @@
using DysonNetwork.Sphere.Account;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;