: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,30 +0,0 @@
using System.ComponentModel.DataAnnotations;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public enum AbuseReportType
{
Copyright,
Harassment,
Impersonation,
OffensiveContent,
Spam,
PrivacyViolation,
IllegalContent,
Other
}
public class AbuseReport : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(4096)] public string ResourceIdentifier { get; set; } = null!;
public AbuseReportType Type { get; set; }
[MaxLength(8192)] public string Reason { get; set; } = null!;
public Instant? ResolvedAt { get; set; }
[MaxLength(8192)] public string? Resolution { get; set; }
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
}

View File

@ -1,196 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Wallet;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using OtpNet;
namespace DysonNetwork.Sphere.Account;
[Index(nameof(Name), IsUnique = true)]
public class Account : ModelBase
{
public Guid Id { get; set; }
[MaxLength(256)] public string Name { get; set; } = string.Empty;
[MaxLength(256)] public string Nick { get; set; } = string.Empty;
[MaxLength(32)] public string Language { get; set; } = string.Empty;
public Instant? ActivatedAt { get; set; }
public bool IsSuperuser { get; set; } = false;
public Profile Profile { get; set; } = null!;
public ICollection<AccountContact> Contacts { get; set; } = new List<AccountContact>();
public ICollection<Badge> Badges { get; set; } = new List<Badge>();
[JsonIgnore] public ICollection<AccountAuthFactor> AuthFactors { get; set; } = new List<AccountAuthFactor>();
[JsonIgnore] public ICollection<AccountConnection> Connections { get; set; } = new List<AccountConnection>();
[JsonIgnore] public ICollection<Auth.Session> Sessions { get; set; } = new List<Auth.Session>();
[JsonIgnore] public ICollection<Auth.Challenge> Challenges { get; set; } = new List<Auth.Challenge>();
[JsonIgnore] public ICollection<Relationship> OutgoingRelationships { get; set; } = new List<Relationship>();
[JsonIgnore] public ICollection<Relationship> IncomingRelationships { get; set; } = new List<Relationship>();
[JsonIgnore] public ICollection<Subscription> Subscriptions { get; set; } = new List<Subscription>();
}
public abstract class Leveling
{
public static readonly List<int> ExperiencePerLevel =
[
0, // Level 0
100, // Level 1
250, // Level 2
500, // Level 3
1000, // Level 4
2000, // Level 5
4000, // Level 6
8000, // Level 7
16000, // Level 8
32000, // Level 9
64000, // Level 10
128000, // Level 11
256000, // Level 12
512000, // Level 13
1024000 // Level 14
];
}
public class Profile : ModelBase
{
public Guid Id { get; set; }
[MaxLength(256)] public string? FirstName { get; set; }
[MaxLength(256)] public string? MiddleName { get; set; }
[MaxLength(256)] public string? LastName { get; set; }
[MaxLength(4096)] public string? Bio { get; set; }
[MaxLength(1024)] public string? Gender { get; set; }
[MaxLength(1024)] public string? Pronouns { get; set; }
[MaxLength(1024)] public string? TimeZone { get; set; }
[MaxLength(1024)] public string? Location { get; set; }
public Instant? Birthday { get; set; }
public Instant? LastSeenAt { get; set; }
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
[Column(TypeName = "jsonb")] public BadgeReferenceObject? ActiveBadge { get; set; }
[Column(TypeName = "jsonb")] public SubscriptionReferenceObject? StellarMembership { get; set; }
public int Experience { get; set; } = 0;
[NotMapped] public int Level => Leveling.ExperiencePerLevel.Count(xp => Experience >= xp) - 1;
[NotMapped]
public double LevelingProgress => Level >= Leveling.ExperiencePerLevel.Count - 1
? 100
: (Experience - Leveling.ExperiencePerLevel[Level]) * 100.0 /
(Leveling.ExperiencePerLevel[Level + 1] - Leveling.ExperiencePerLevel[Level]);
// Outdated fields, for backward compability
[MaxLength(32)] public string? PictureId { get; set; }
[MaxLength(32)] public string? BackgroundId { get; set; }
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!;
}
public class AccountContact : ModelBase
{
public Guid Id { get; set; }
public AccountContactType Type { get; set; }
public Instant? VerifiedAt { get; set; }
public bool IsPrimary { get; set; } = false;
[MaxLength(1024)] public string Content { get; set; } = string.Empty;
public Guid AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!;
}
public enum AccountContactType
{
Email,
PhoneNumber,
Address
}
public class AccountAuthFactor : ModelBase
{
public Guid Id { get; set; }
public AccountAuthFactorType Type { get; set; }
[JsonIgnore] [MaxLength(8196)] public string? Secret { get; set; }
[JsonIgnore]
[Column(TypeName = "jsonb")]
public Dictionary<string, object>? Config { get; set; } = new();
/// <summary>
/// The trustworthy stands for how safe is this auth factor.
/// Basically, it affects how many steps it can complete in authentication.
/// Besides, users may need to use some high-trustworthy level auth factors when confirming some dangerous operations.
/// </summary>
public int Trustworthy { get; set; } = 1;
public Instant? EnabledAt { get; set; }
public Instant? ExpiredAt { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!;
public AccountAuthFactor HashSecret(int cost = 12)
{
if (Secret == null) return this;
Secret = BCrypt.Net.BCrypt.HashPassword(Secret, workFactor: cost);
return this;
}
public bool VerifyPassword(string password)
{
if (Secret == null)
throw new InvalidOperationException("Auth factor with no secret cannot be verified with password.");
switch (Type)
{
case AccountAuthFactorType.Password:
case AccountAuthFactorType.PinCode:
return BCrypt.Net.BCrypt.Verify(password, Secret);
case AccountAuthFactorType.TimedCode:
var otp = new Totp(Base32Encoding.ToBytes(Secret));
return otp.VerifyTotp(DateTime.UtcNow, password, out _, new VerificationWindow(previous: 5, future: 5));
case AccountAuthFactorType.EmailCode:
case AccountAuthFactorType.InAppCode:
default:
throw new InvalidOperationException("Unsupported verification type, use CheckDeliveredCode instead.");
}
}
/// <summary>
/// This dictionary will be returned to the client and should only be set when it just created.
/// Useful for passing the client some data to finishing setup and recovery code.
/// </summary>
[NotMapped]
public Dictionary<string, object>? CreatedResponse { get; set; }
}
public enum AccountAuthFactorType
{
Password,
EmailCode,
InAppCode,
TimedCode,
PinCode,
}
public class AccountConnection : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(4096)] public string Provider { get; set; } = null!;
[MaxLength(8192)] public string ProvidedIdentifier { get; set; } = null!;
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; } = new();
[JsonIgnore] [MaxLength(4096)] public string? AccessToken { get; set; }
[JsonIgnore] [MaxLength(4096)] public string? RefreshToken { get; set; }
public Instant? LastUsedAt { get; set; }
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
}

View File

@ -1,177 +0,0 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Permission;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using NodaTime.Extensions;
using System.Collections.Generic;
namespace DysonNetwork.Sphere.Account;
[ApiController]
[Route("/accounts")]
public class AccountController(
AppDatabase db,
AuthService auth,
AccountService accounts,
AccountEventService events
) : ControllerBase
{
[HttpGet("{name}")]
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Account?>> GetByName(string name)
{
var account = await db.Accounts
.Include(e => e.Badges)
.Include(e => e.Profile)
.Where(a => a.Name == name)
.FirstOrDefaultAsync();
return account is null ? new NotFoundResult() : account;
}
[HttpGet("{name}/badges")]
[ProducesResponseType<List<Badge>>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<List<Badge>>> GetBadgesByName(string name)
{
var account = await db.Accounts
.Include(e => e.Badges)
.Where(a => a.Name == name)
.FirstOrDefaultAsync();
return account is null ? NotFound() : account.Badges.ToList();
}
public class AccountCreateRequest
{
[Required]
[MinLength(2)]
[MaxLength(256)]
[RegularExpression(@"^[A-Za-z0-9_-]+$",
ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.")
]
public string Name { get; set; } = string.Empty;
[Required] [MaxLength(256)] public string Nick { get; set; } = string.Empty;
[EmailAddress]
[RegularExpression(@"^[^+]+@[^@]+\.[^@]+$", ErrorMessage = "Email address cannot contain '+' symbol.")]
[Required]
[MaxLength(1024)]
public string Email { get; set; } = string.Empty;
[Required]
[MinLength(4)]
[MaxLength(128)]
public string Password { get; set; } = string.Empty;
[MaxLength(128)] public string Language { get; set; } = "en-us";
[Required] public string CaptchaToken { get; set; } = string.Empty;
}
[HttpPost]
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<Account>> CreateAccount([FromBody] AccountCreateRequest request)
{
if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token.");
try
{
var account = await accounts.CreateAccount(
request.Name,
request.Nick,
request.Email,
request.Password,
request.Language
);
return Ok(account);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
public class RecoveryPasswordRequest
{
[Required] public string Account { get; set; } = null!;
[Required] public string CaptchaToken { get; set; } = null!;
}
[HttpPost("recovery/password")]
public async Task<ActionResult> RequestResetPassword([FromBody] RecoveryPasswordRequest request)
{
if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token.");
var account = await accounts.LookupAccount(request.Account);
if (account is null) return BadRequest("Unable to find the account.");
try
{
await accounts.RequestPasswordReset(account);
}
catch (InvalidOperationException)
{
return BadRequest("You already requested password reset within 24 hours.");
}
return Ok();
}
public class StatusRequest
{
public StatusAttitude Attitude { get; set; }
public bool IsInvisible { get; set; }
public bool IsNotDisturb { get; set; }
[MaxLength(1024)] public string? Label { get; set; }
public Instant? ClearedAt { get; set; }
}
[HttpGet("{name}/statuses")]
public async Task<ActionResult<Status>> GetOtherStatus(string name)
{
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name);
if (account is null) return BadRequest();
var status = await events.GetStatus(account.Id);
status.IsInvisible = false; // Keep the invisible field not available for other users
return Ok(status);
}
[HttpGet("{name}/calendar")]
public async Task<ActionResult<List<DailyEventResponse>>> GetOtherEventCalendar(
string name,
[FromQuery] int? month,
[FromQuery] int? year
)
{
var currentDate = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
month ??= currentDate.Month;
year ??= currentDate.Year;
if (month is < 1 or > 12) return BadRequest("Invalid month.");
if (year < 1) return BadRequest("Invalid year.");
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name);
if (account is null) return BadRequest();
var calendar = await events.GetEventCalendar(account, month.Value, year.Value, replaceInvisible: true);
return Ok(calendar);
}
[HttpGet("search")]
public async Task<List<Account>> Search([FromQuery] string query, [FromQuery] int take = 20)
{
if (string.IsNullOrWhiteSpace(query))
return [];
return await db.Accounts
.Include(e => e.Profile)
.Where(a => EF.Functions.ILike(a.Name, $"%{query}%") ||
EF.Functions.ILike(a.Nick, $"%{query}%"))
.Take(take)
.ToListAsync();
}
}

View File

@ -1,703 +0,0 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Org.BouncyCastle.Utilities;
namespace DysonNetwork.Sphere.Account;
[Authorize]
[ApiController]
[Route("/accounts/me")]
public class AccountCurrentController(
AppDatabase db,
AccountService accounts,
FileReferenceService fileRefService,
AccountEventService events,
AuthService auth
) : ControllerBase
{
[HttpGet]
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
public async Task<ActionResult<Account>> GetCurrentIdentity()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var account = await db.Accounts
.Include(e => e.Badges)
.Include(e => e.Profile)
.Where(e => e.Id == userId)
.FirstOrDefaultAsync();
return Ok(account);
}
public class BasicInfoRequest
{
[MaxLength(256)] public string? Nick { get; set; }
[MaxLength(32)] public string? Language { get; set; }
}
[HttpPatch]
public async Task<ActionResult<Account>> UpdateBasicInfo([FromBody] BasicInfoRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var account = await db.Accounts.FirstAsync(a => a.Id == currentUser.Id);
if (request.Nick is not null) account.Nick = request.Nick;
if (request.Language is not null) account.Language = request.Language;
await db.SaveChangesAsync();
await accounts.PurgeAccountCache(currentUser);
return currentUser;
}
public class ProfileRequest
{
[MaxLength(256)] public string? FirstName { get; set; }
[MaxLength(256)] public string? MiddleName { get; set; }
[MaxLength(256)] public string? LastName { get; set; }
[MaxLength(1024)] public string? Gender { get; set; }
[MaxLength(1024)] public string? Pronouns { get; set; }
[MaxLength(1024)] public string? TimeZone { get; set; }
[MaxLength(1024)] public string? Location { get; set; }
[MaxLength(4096)] public string? Bio { get; set; }
public Instant? Birthday { get; set; }
[MaxLength(32)] public string? PictureId { get; set; }
[MaxLength(32)] public string? BackgroundId { get; set; }
}
[HttpPatch("profile")]
public async Task<ActionResult<Profile>> UpdateProfile([FromBody] ProfileRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var profile = await db.AccountProfiles
.Where(p => p.Account.Id == userId)
.FirstOrDefaultAsync();
if (profile is null) return BadRequest("Unable to get your account.");
if (request.FirstName is not null) profile.FirstName = request.FirstName;
if (request.MiddleName is not null) profile.MiddleName = request.MiddleName;
if (request.LastName is not null) profile.LastName = request.LastName;
if (request.Bio is not null) profile.Bio = request.Bio;
if (request.Gender is not null) profile.Gender = request.Gender;
if (request.Pronouns is not null) profile.Pronouns = request.Pronouns;
if (request.Birthday is not null) profile.Birthday = request.Birthday;
if (request.Location is not null) profile.Location = request.Location;
if (request.TimeZone is not null) profile.TimeZone = request.TimeZone;
if (request.PictureId is not null)
{
var picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync();
if (picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud.");
var profileResourceId = $"profile:{profile.Id}";
// Remove old references for the profile picture
if (profile.Picture is not null)
{
var oldPictureRefs =
await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.picture");
foreach (var oldRef in oldPictureRefs)
{
await fileRefService.DeleteReferenceAsync(oldRef.Id);
}
}
profile.Picture = picture.ToReferenceObject();
// Create new reference
await fileRefService.CreateReferenceAsync(
picture.Id,
"profile.picture",
profileResourceId
);
}
if (request.BackgroundId is not null)
{
var background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync();
if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud.");
var profileResourceId = $"profile:{profile.Id}";
// Remove old references for the profile background
if (profile.Background is not null)
{
var oldBackgroundRefs =
await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.background");
foreach (var oldRef in oldBackgroundRefs)
{
await fileRefService.DeleteReferenceAsync(oldRef.Id);
}
}
profile.Background = background.ToReferenceObject();
// Create new reference
await fileRefService.CreateReferenceAsync(
background.Id,
"profile.background",
profileResourceId
);
}
db.Update(profile);
await db.SaveChangesAsync();
await accounts.PurgeAccountCache(currentUser);
return profile;
}
[HttpDelete]
public async Task<ActionResult> RequestDeleteAccount()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
try
{
await accounts.RequestAccountDeletion(currentUser);
}
catch (InvalidOperationException)
{
return BadRequest("You already requested account deletion within 24 hours.");
}
return Ok();
}
[HttpGet("statuses")]
public async Task<ActionResult<Status>> GetCurrentStatus()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var status = await events.GetStatus(currentUser.Id);
return Ok(status);
}
[HttpPatch("statuses")]
[RequiredPermission("global", "accounts.statuses.update")]
public async Task<ActionResult<Status>> UpdateStatus([FromBody] AccountController.StatusRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var now = SystemClock.Instance.GetCurrentInstant();
var status = await db.AccountStatuses
.Where(e => e.AccountId == currentUser.Id)
.Where(e => e.ClearedAt == null || e.ClearedAt > now)
.OrderByDescending(e => e.CreatedAt)
.FirstOrDefaultAsync();
if (status is null) return NotFound();
status.Attitude = request.Attitude;
status.IsInvisible = request.IsInvisible;
status.IsNotDisturb = request.IsNotDisturb;
status.Label = request.Label;
status.ClearedAt = request.ClearedAt;
db.Update(status);
await db.SaveChangesAsync();
events.PurgeStatusCache(currentUser.Id);
return status;
}
[HttpPost("statuses")]
[RequiredPermission("global", "accounts.statuses.create")]
public async Task<ActionResult<Status>> CreateStatus([FromBody] AccountController.StatusRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var status = new Status
{
AccountId = currentUser.Id,
Attitude = request.Attitude,
IsInvisible = request.IsInvisible,
IsNotDisturb = request.IsNotDisturb,
Label = request.Label,
ClearedAt = request.ClearedAt
};
return await events.CreateStatus(currentUser, status);
}
[HttpDelete("me/statuses")]
public async Task<ActionResult> DeleteStatus()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var now = SystemClock.Instance.GetCurrentInstant();
var status = await db.AccountStatuses
.Where(s => s.AccountId == currentUser.Id)
.Where(s => s.ClearedAt == null || s.ClearedAt > now)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();
if (status is null) return NotFound();
await events.ClearStatus(currentUser, status);
return NoContent();
}
[HttpGet("check-in")]
public async Task<ActionResult<CheckInResult>> GetCheckInResult()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var now = SystemClock.Instance.GetCurrentInstant();
var today = now.InUtc().Date;
var startOfDay = today.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
var endOfDay = today.PlusDays(1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
var result = await db.AccountCheckInResults
.Where(x => x.AccountId == userId)
.Where(x => x.CreatedAt >= startOfDay && x.CreatedAt < endOfDay)
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync();
return result is null ? NotFound() : Ok(result);
}
[HttpPost("check-in")]
public async Task<ActionResult<CheckInResult>> DoCheckIn([FromBody] string? captchaToken)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var isAvailable = await events.CheckInDailyIsAvailable(currentUser);
if (!isAvailable)
return BadRequest("Check-in is not available for today.");
try
{
var needsCaptcha = await events.CheckInDailyDoAskCaptcha(currentUser);
return needsCaptcha switch
{
true when string.IsNullOrWhiteSpace(captchaToken) => StatusCode(423,
"Captcha is required for this check-in."),
true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest("Invalid captcha token."),
_ => await events.CheckInDaily(currentUser)
};
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("calendar")]
public async Task<ActionResult<List<DailyEventResponse>>> GetEventCalendar([FromQuery] int? month,
[FromQuery] int? year)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var currentDate = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
month ??= currentDate.Month;
year ??= currentDate.Year;
if (month is < 1 or > 12) return BadRequest("Invalid month.");
if (year < 1) return BadRequest("Invalid year.");
var calendar = await events.GetEventCalendar(currentUser, month.Value, year.Value);
return Ok(calendar);
}
[HttpGet("actions")]
[ProducesResponseType<List<ActionLog>>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<List<ActionLog>>> GetActionLogs(
[FromQuery] int take = 20,
[FromQuery] int offset = 0
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var query = db.ActionLogs
.Where(log => log.AccountId == currentUser.Id)
.OrderByDescending(log => log.CreatedAt);
var total = await query.CountAsync();
Response.Headers.Append("X-Total", total.ToString());
var logs = await query
.Skip(offset)
.Take(take)
.ToListAsync();
return Ok(logs);
}
[HttpGet("factors")]
public async Task<ActionResult<List<AccountAuthFactor>>> GetAuthFactors()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var factors = await db.AccountAuthFactors
.Include(f => f.Account)
.Where(f => f.Account.Id == currentUser.Id)
.ToListAsync();
return Ok(factors);
}
public class AuthFactorRequest
{
public AccountAuthFactorType Type { get; set; }
public string? Secret { get; set; }
}
[HttpPost("factors")]
[Authorize]
public async Task<ActionResult<AccountAuthFactor>> CreateAuthFactor([FromBody] AuthFactorRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
if (await accounts.CheckAuthFactorExists(currentUser, request.Type))
return BadRequest($"Auth factor with type {request.Type} is already exists.");
var factor = await accounts.CreateAuthFactor(currentUser, request.Type, request.Secret);
return Ok(factor);
}
[HttpPost("factors/{id:guid}/enable")]
[Authorize]
public async Task<ActionResult<AccountAuthFactor>> EnableAuthFactor(Guid id, [FromBody] string? code)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var factor = await db.AccountAuthFactors
.Where(f => f.AccountId == currentUser.Id && f.Id == id)
.FirstOrDefaultAsync();
if (factor is null) return NotFound();
try
{
factor = await accounts.EnableAuthFactor(factor, code);
return Ok(factor);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpPost("factors/{id:guid}/disable")]
[Authorize]
public async Task<ActionResult<AccountAuthFactor>> DisableAuthFactor(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var factor = await db.AccountAuthFactors
.Where(f => f.AccountId == currentUser.Id && f.Id == id)
.FirstOrDefaultAsync();
if (factor is null) return NotFound();
try
{
factor = await accounts.DisableAuthFactor(factor);
return Ok(factor);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpDelete("factors/{id:guid}")]
[Authorize]
public async Task<ActionResult<AccountAuthFactor>> DeleteAuthFactor(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var factor = await db.AccountAuthFactors
.Where(f => f.AccountId == currentUser.Id && f.Id == id)
.FirstOrDefaultAsync();
if (factor is null) return NotFound();
try
{
await accounts.DeleteAuthFactor(factor);
return NoContent();
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
public class AuthorizedDevice
{
public string? Label { get; set; }
public string UserAgent { get; set; } = null!;
public string DeviceId { get; set; } = null!;
public ChallengePlatform Platform { get; set; }
public List<Session> Sessions { get; set; } = [];
}
[HttpGet("devices")]
[Authorize]
public async Task<ActionResult<List<AuthorizedDevice>>> GetDevices()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
// Group sessions by the related DeviceId, then create an AuthorizedDevice for each group.
var deviceGroups = await db.AuthSessions
.Where(s => s.Account.Id == currentUser.Id)
.Include(s => s.Challenge)
.GroupBy(s => s.Challenge.DeviceId!)
.Select(g => new AuthorizedDevice
{
DeviceId = g.Key!,
UserAgent = g.First(x => x.Challenge.UserAgent != null).Challenge.UserAgent!,
Platform = g.First().Challenge.Platform!,
Label = g.Where(x => !string.IsNullOrWhiteSpace(x.Label)).Select(x => x.Label).FirstOrDefault(),
Sessions = g
.OrderByDescending(x => x.LastGrantedAt)
.ToList()
})
.ToListAsync();
deviceGroups = deviceGroups
.OrderByDescending(s => s.Sessions.First().LastGrantedAt)
.ToList();
return Ok(deviceGroups);
}
[HttpGet("sessions")]
[Authorize]
public async Task<ActionResult<List<Session>>> GetSessions(
[FromQuery] int take = 20,
[FromQuery] int offset = 0
)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
var query = db.AuthSessions
.Include(session => session.Account)
.Include(session => session.Challenge)
.Where(session => session.Account.Id == currentUser.Id);
var total = await query.CountAsync();
Response.Headers.Append("X-Total", total.ToString());
Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
var sessions = await query
.OrderByDescending(x => x.LastGrantedAt)
.Skip(offset)
.Take(take)
.ToListAsync();
return Ok(sessions);
}
[HttpDelete("sessions/{id:guid}")]
[Authorize]
public async Task<ActionResult<Session>> DeleteSession(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
try
{
await accounts.DeleteSession(currentUser, id);
return NoContent();
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpDelete("sessions/current")]
[Authorize]
public async Task<ActionResult<Session>> DeleteCurrentSession()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
try
{
await accounts.DeleteSession(currentUser, currentSession.Id);
return NoContent();
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpPatch("sessions/{id:guid}/label")]
public async Task<ActionResult<Session>> UpdateSessionLabel(Guid id, [FromBody] string label)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
try
{
await accounts.UpdateSessionLabel(currentUser, id, label);
return NoContent();
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpPatch("sessions/current/label")]
public async Task<ActionResult<Session>> UpdateCurrentSessionLabel([FromBody] string label)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
try
{
await accounts.UpdateSessionLabel(currentUser, currentSession.Id, label);
return NoContent();
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("contacts")]
[Authorize]
public async Task<ActionResult<List<AccountContact>>> GetContacts()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var contacts = await db.AccountContacts
.Where(c => c.AccountId == currentUser.Id)
.ToListAsync();
return Ok(contacts);
}
public class AccountContactRequest
{
[Required] public AccountContactType Type { get; set; }
[Required] public string Content { get; set; } = null!;
}
[HttpPost("contacts")]
[Authorize]
public async Task<ActionResult<AccountContact>> CreateContact([FromBody] AccountContactRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
try
{
var contact = await accounts.CreateContactMethod(currentUser, request.Type, request.Content);
return Ok(contact);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpPost("contacts/{id:guid}/verify")]
[Authorize]
public async Task<ActionResult<AccountContact>> VerifyContact(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var contact = await db.AccountContacts
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
.FirstOrDefaultAsync();
if (contact is null) return NotFound();
try
{
await accounts.VerifyContactMethod(currentUser, contact);
return Ok(contact);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpPost("contacts/{id:guid}/primary")]
[Authorize]
public async Task<ActionResult<AccountContact>> SetPrimaryContact(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var contact = await db.AccountContacts
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
.FirstOrDefaultAsync();
if (contact is null) return NotFound();
try
{
contact = await accounts.SetContactMethodPrimary(currentUser, contact);
return Ok(contact);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpDelete("contacts/{id:guid}")]
[Authorize]
public async Task<ActionResult<AccountContact>> DeleteContact(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var contact = await db.AccountContacts
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
.FirstOrDefaultAsync();
if (contact is null) return NotFound();
try
{
await accounts.DeleteContactMethod(currentUser, contact);
return NoContent();
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("badges")]
[ProducesResponseType<List<Badge>>(StatusCodes.Status200OK)]
[Authorize]
public async Task<ActionResult<List<Badge>>> GetBadges()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var badges = await db.Badges
.Where(b => b.AccountId == currentUser.Id)
.ToListAsync();
return Ok(badges);
}
[HttpPost("badges/{id:guid}/active")]
[Authorize]
public async Task<ActionResult<Badge>> ActivateBadge(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
try
{
await accounts.ActiveBadge(currentUser, id);
return Ok();
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
}

View File

@ -1,339 +0,0 @@
using System.Globalization;
using DysonNetwork.Sphere.Activity;
using DysonNetwork.Sphere.Connection;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Wallet;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Localization;
using NodaTime;
using Org.BouncyCastle.Asn1.X509;
namespace DysonNetwork.Sphere.Account;
public class AccountEventService(
AppDatabase db,
WebSocketService ws,
ICacheService cache,
PaymentService payment,
IStringLocalizer<Localization.AccountEventResource> localizer
)
{
private static readonly Random Random = new();
private const string StatusCacheKey = "AccountStatus_";
public void PurgeStatusCache(Guid userId)
{
var cacheKey = $"{StatusCacheKey}{userId}";
cache.RemoveAsync(cacheKey);
}
public async Task<Status> GetStatus(Guid userId)
{
var cacheKey = $"{StatusCacheKey}{userId}";
var cachedStatus = await cache.GetAsync<Status>(cacheKey);
if (cachedStatus is not null)
{
cachedStatus!.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId);
return cachedStatus;
}
var now = SystemClock.Instance.GetCurrentInstant();
var status = await db.AccountStatuses
.Where(e => e.AccountId == userId)
.Where(e => e.ClearedAt == null || e.ClearedAt > now)
.OrderByDescending(e => e.CreatedAt)
.FirstOrDefaultAsync();
var isOnline = ws.GetAccountIsConnected(userId);
if (status is not null)
{
status.IsOnline = !status.IsInvisible && isOnline;
await cache.SetWithGroupsAsync(cacheKey, status, [$"{AccountService.AccountCachePrefix}{status.AccountId}"],
TimeSpan.FromMinutes(5));
return status;
}
if (isOnline)
{
return new Status
{
Attitude = StatusAttitude.Neutral,
IsOnline = true,
IsCustomized = false,
Label = "Online",
AccountId = userId,
};
}
return new Status
{
Attitude = StatusAttitude.Neutral,
IsOnline = false,
IsCustomized = false,
Label = "Offline",
AccountId = userId,
};
}
public async Task<Dictionary<Guid, Status>> GetStatuses(List<Guid> userIds)
{
var results = new Dictionary<Guid, Status>();
var cacheMissUserIds = new List<Guid>();
foreach (var userId in userIds)
{
var cacheKey = $"{StatusCacheKey}{userId}";
var cachedStatus = await cache.GetAsync<Status>(cacheKey);
if (cachedStatus != null)
{
cachedStatus.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId);
results[userId] = cachedStatus;
}
else
{
cacheMissUserIds.Add(userId);
}
}
if (cacheMissUserIds.Any())
{
var now = SystemClock.Instance.GetCurrentInstant();
var statusesFromDb = await db.AccountStatuses
.Where(e => cacheMissUserIds.Contains(e.AccountId))
.Where(e => e.ClearedAt == null || e.ClearedAt > now)
.GroupBy(e => e.AccountId)
.Select(g => g.OrderByDescending(e => e.CreatedAt).First())
.ToListAsync();
var foundUserIds = new HashSet<Guid>();
foreach (var status in statusesFromDb)
{
var isOnline = ws.GetAccountIsConnected(status.AccountId);
status.IsOnline = !status.IsInvisible && isOnline;
results[status.AccountId] = status;
var cacheKey = $"{StatusCacheKey}{status.AccountId}";
await cache.SetAsync(cacheKey, status, TimeSpan.FromMinutes(5));
foundUserIds.Add(status.AccountId);
}
var usersWithoutStatus = cacheMissUserIds.Except(foundUserIds).ToList();
if (usersWithoutStatus.Any())
{
foreach (var userId in usersWithoutStatus)
{
var isOnline = ws.GetAccountIsConnected(userId);
var defaultStatus = new Status
{
Attitude = StatusAttitude.Neutral,
IsOnline = isOnline,
IsCustomized = false,
Label = isOnline ? "Online" : "Offline",
AccountId = userId,
};
results[userId] = defaultStatus;
}
}
}
return results;
}
public async Task<Status> CreateStatus(Account user, Status status)
{
var now = SystemClock.Instance.GetCurrentInstant();
await db.AccountStatuses
.Where(x => x.AccountId == user.Id && (x.ClearedAt == null || x.ClearedAt > now))
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ClearedAt, now));
db.AccountStatuses.Add(status);
await db.SaveChangesAsync();
return status;
}
public async Task ClearStatus(Account user, Status status)
{
status.ClearedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(status);
await db.SaveChangesAsync();
PurgeStatusCache(user.Id);
}
private const int FortuneTipCount = 7; // This will be the max index for each type (positive/negative)
private const string CaptchaCacheKey = "CheckInCaptcha_";
private const int CaptchaProbabilityPercent = 20;
public async Task<bool> CheckInDailyDoAskCaptcha(Account user)
{
var cacheKey = $"{CaptchaCacheKey}{user.Id}";
var needsCaptcha = await cache.GetAsync<bool?>(cacheKey);
if (needsCaptcha is not null)
return needsCaptcha!.Value;
var result = Random.Next(100) < CaptchaProbabilityPercent;
await cache.SetAsync(cacheKey, result, TimeSpan.FromHours(24));
return result;
}
public async Task<bool> CheckInDailyIsAvailable(Account user)
{
var now = SystemClock.Instance.GetCurrentInstant();
var lastCheckIn = await db.AccountCheckInResults
.Where(x => x.AccountId == user.Id)
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync();
if (lastCheckIn == null)
return true;
var lastDate = lastCheckIn.CreatedAt.InUtc().Date;
var currentDate = now.InUtc().Date;
return lastDate < currentDate;
}
public const string CheckInLockKey = "CheckInLock_";
public async Task<CheckInResult> CheckInDaily(Account user)
{
var lockKey = $"{CheckInLockKey}{user.Id}";
try
{
var lk = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(100));
if (lk != null)
await lk.ReleaseAsync();
}
catch
{
// Ignore errors from this pre-check
}
// Now try to acquire the lock properly
await using var lockObj = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5));
if (lockObj is null) throw new InvalidOperationException("Check-in was in progress.");
var cultureInfo = new CultureInfo(user.Language, false);
CultureInfo.CurrentCulture = cultureInfo;
CultureInfo.CurrentUICulture = cultureInfo;
// Generate 2 positive tips
var positiveIndices = Enumerable.Range(1, FortuneTipCount)
.OrderBy(_ => Random.Next())
.Take(2)
.ToList();
var tips = positiveIndices.Select(index => new FortuneTip
{
IsPositive = true, Title = localizer[$"FortuneTipPositiveTitle_{index}"].Value,
Content = localizer[$"FortuneTipPositiveContent_{index}"].Value
}).ToList();
// Generate 2 negative tips
var negativeIndices = Enumerable.Range(1, FortuneTipCount)
.Except(positiveIndices)
.OrderBy(_ => Random.Next())
.Take(2)
.ToList();
tips.AddRange(negativeIndices.Select(index => new FortuneTip
{
IsPositive = false, Title = localizer[$"FortuneTipNegativeTitle_{index}"].Value,
Content = localizer[$"FortuneTipNegativeContent_{index}"].Value
}));
var result = new CheckInResult
{
Tips = tips,
Level = (CheckInResultLevel)Random.Next(Enum.GetValues<CheckInResultLevel>().Length),
AccountId = user.Id,
RewardExperience = 100,
RewardPoints = 10,
};
var now = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
try
{
if (result.RewardPoints.HasValue)
await payment.CreateTransactionWithAccountAsync(
null,
user.Id,
WalletCurrency.SourcePoint,
result.RewardPoints.Value,
$"Check-in reward on {now:yyyy/MM/dd}"
);
}
catch
{
result.RewardPoints = null;
}
await db.AccountProfiles
.Where(p => p.AccountId == user.Id)
.ExecuteUpdateAsync(s =>
s.SetProperty(b => b.Experience, b => b.Experience + result.RewardExperience)
);
db.AccountCheckInResults.Add(result);
await db.SaveChangesAsync(); // Don't forget to save changes to the database
// The lock will be automatically released by the await using statement
return result;
}
public async Task<List<DailyEventResponse>> GetEventCalendar(Account user, int month, int year = 0,
bool replaceInvisible = false)
{
if (year == 0)
year = SystemClock.Instance.GetCurrentInstant().InUtc().Date.Year;
// Create start and end dates for the specified month
var startOfMonth = new LocalDate(year, month, 1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
var endOfMonth = startOfMonth.Plus(Duration.FromDays(DateTime.DaysInMonth(year, month)));
var statuses = await db.AccountStatuses
.AsNoTracking()
.TagWith("GetEventCalendar_Statuses")
.Where(x => x.AccountId == user.Id && x.CreatedAt >= startOfMonth && x.CreatedAt < endOfMonth)
.Select(x => new Status
{
Id = x.Id,
Attitude = x.Attitude,
IsInvisible = !replaceInvisible && x.IsInvisible,
IsNotDisturb = x.IsNotDisturb,
Label = x.Label,
ClearedAt = x.ClearedAt,
AccountId = x.AccountId,
CreatedAt = x.CreatedAt
})
.OrderBy(x => x.CreatedAt)
.ToListAsync();
var checkIn = await db.AccountCheckInResults
.AsNoTracking()
.TagWith("GetEventCalendar_CheckIn")
.Where(x => x.AccountId == user.Id && x.CreatedAt >= startOfMonth && x.CreatedAt < endOfMonth)
.ToListAsync();
var dates = Enumerable.Range(1, DateTime.DaysInMonth(year, month))
.Select(day => new LocalDate(year, month, day).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant())
.ToList();
var statusesByDate = statuses
.GroupBy(s => s.CreatedAt.InUtc().Date)
.ToDictionary(g => g.Key, g => g.ToList());
var checkInByDate = checkIn
.ToDictionary(c => c.CreatedAt.InUtc().Date);
return dates.Select(date =>
{
var utcDate = date.InUtc().Date;
return new DailyEventResponse
{
Date = date,
CheckInResult = checkInByDate.GetValueOrDefault(utcDate),
Statuses = statusesByDate.GetValueOrDefault(utcDate, new List<Status>())
};
}).ToList();
}
}

View File

@ -1,657 +0,0 @@
using System.Globalization;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Auth.OpenId;
using DysonNetwork.Sphere.Email;
using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Storage;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using NodaTime;
using Org.BouncyCastle.Utilities;
using OtpNet;
namespace DysonNetwork.Sphere.Account;
public class AccountService(
AppDatabase db,
MagicSpellService spells,
AccountUsernameService uname,
NotificationService nty,
EmailService mailer,
IStringLocalizer<NotificationResource> localizer,
ICacheService cache,
ILogger<AccountService> logger
)
{
public static void SetCultureInfo(Account account)
{
SetCultureInfo(account.Language);
}
public static void SetCultureInfo(string? languageCode)
{
var info = new CultureInfo(languageCode ?? "en-us", false);
CultureInfo.CurrentCulture = info;
CultureInfo.CurrentUICulture = info;
}
public const string AccountCachePrefix = "account:";
public async Task PurgeAccountCache(Account account)
{
await cache.RemoveGroupAsync($"{AccountCachePrefix}{account.Id}");
}
public async Task<Account?> LookupAccount(string probe)
{
var account = await db.Accounts.Where(a => a.Name == probe).FirstOrDefaultAsync();
if (account is not null) return account;
var contact = await db.AccountContacts
.Where(c => c.Content == probe)
.Include(c => c.Account)
.FirstOrDefaultAsync();
return contact?.Account;
}
public async Task<Account?> LookupAccountByConnection(string identifier, string provider)
{
var connection = await db.AccountConnections
.Where(c => c.ProvidedIdentifier == identifier && c.Provider == provider)
.Include(c => c.Account)
.FirstOrDefaultAsync();
return connection?.Account;
}
public async Task<int?> GetAccountLevel(Guid accountId)
{
var profile = await db.AccountProfiles
.Where(a => a.AccountId == accountId)
.FirstOrDefaultAsync();
return profile?.Level;
}
public async Task<Account> CreateAccount(
string name,
string nick,
string email,
string? password,
string language = "en-US",
bool isEmailVerified = false,
bool isActivated = false
)
{
await using var transaction = await db.Database.BeginTransactionAsync();
try
{
var dupeNameCount = await db.Accounts.Where(a => a.Name == name).CountAsync();
if (dupeNameCount > 0)
throw new InvalidOperationException("Account name has already been taken.");
var account = new Account
{
Name = name,
Nick = nick,
Language = language,
Contacts = new List<AccountContact>
{
new()
{
Type = AccountContactType.Email,
Content = email,
VerifiedAt = isEmailVerified ? SystemClock.Instance.GetCurrentInstant() : null,
IsPrimary = true
}
},
AuthFactors = password is not null
? new List<AccountAuthFactor>
{
new AccountAuthFactor
{
Type = AccountAuthFactorType.Password,
Secret = password,
EnabledAt = SystemClock.Instance.GetCurrentInstant()
}.HashSecret()
}
: [],
Profile = new Profile()
};
if (isActivated)
{
account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
var defaultGroup = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Key == "default");
if (defaultGroup is not null)
{
db.PermissionGroupMembers.Add(new PermissionGroupMember
{
Actor = $"user:{account.Id}",
Group = defaultGroup
});
}
}
else
{
var spell = await spells.CreateMagicSpell(
account,
MagicSpellType.AccountActivation,
new Dictionary<string, object>
{
{ "contact_method", account.Contacts.First().Content }
}
);
await spells.NotifyMagicSpell(spell, true);
}
db.Accounts.Add(account);
await db.SaveChangesAsync();
await transaction.CommitAsync();
return account;
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
public async Task<Account> CreateAccount(OidcUserInfo userInfo)
{
if (string.IsNullOrEmpty(userInfo.Email))
throw new ArgumentException("Email is required for account creation");
var displayName = !string.IsNullOrEmpty(userInfo.DisplayName)
? userInfo.DisplayName
: $"{userInfo.FirstName} {userInfo.LastName}".Trim();
// Generate username from email
var username = await uname.GenerateUsernameFromEmailAsync(userInfo.Email);
return await CreateAccount(
username,
displayName,
userInfo.Email,
null,
"en-US",
userInfo.EmailVerified,
userInfo.EmailVerified
);
}
public async Task RequestAccountDeletion(Account account)
{
var spell = await spells.CreateMagicSpell(
account,
MagicSpellType.AccountRemoval,
new Dictionary<string, object>(),
SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
preventRepeat: true
);
await spells.NotifyMagicSpell(spell);
}
public async Task RequestPasswordReset(Account account)
{
var spell = await spells.CreateMagicSpell(
account,
MagicSpellType.AuthPasswordReset,
new Dictionary<string, object>(),
SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
preventRepeat: true
);
await spells.NotifyMagicSpell(spell);
}
public async Task<bool> CheckAuthFactorExists(Account account, AccountAuthFactorType type)
{
var isExists = await db.AccountAuthFactors
.Where(x => x.AccountId == account.Id && x.Type == type)
.AnyAsync();
return isExists;
}
public async Task<AccountAuthFactor?> CreateAuthFactor(Account account, AccountAuthFactorType type, string? secret)
{
AccountAuthFactor? factor = null;
switch (type)
{
case AccountAuthFactorType.Password:
if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentNullException(nameof(secret));
factor = new AccountAuthFactor
{
Type = AccountAuthFactorType.Password,
Trustworthy = 1,
AccountId = account.Id,
Secret = secret,
EnabledAt = SystemClock.Instance.GetCurrentInstant(),
}.HashSecret();
break;
case AccountAuthFactorType.EmailCode:
factor = new AccountAuthFactor
{
Type = AccountAuthFactorType.EmailCode,
Trustworthy = 2,
EnabledAt = SystemClock.Instance.GetCurrentInstant(),
};
break;
case AccountAuthFactorType.InAppCode:
factor = new AccountAuthFactor
{
Type = AccountAuthFactorType.InAppCode,
Trustworthy = 1,
EnabledAt = SystemClock.Instance.GetCurrentInstant()
};
break;
case AccountAuthFactorType.TimedCode:
var skOtp = KeyGeneration.GenerateRandomKey(20);
var skOtp32 = Base32Encoding.ToString(skOtp);
factor = new AccountAuthFactor
{
Secret = skOtp32,
Type = AccountAuthFactorType.TimedCode,
Trustworthy = 2,
EnabledAt = null, // It needs to be tired once to enable
CreatedResponse = new Dictionary<string, object>
{
["uri"] = new OtpUri(
OtpType.Totp,
skOtp32,
account.Id.ToString(),
"Solar Network"
).ToString(),
}
};
break;
case AccountAuthFactorType.PinCode:
if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentNullException(nameof(secret));
if (!secret.All(char.IsDigit) || secret.Length != 6)
throw new ArgumentException("PIN code must be exactly 6 digits");
factor = new AccountAuthFactor
{
Type = AccountAuthFactorType.PinCode,
Trustworthy = 0, // Only for confirming, can't be used for login
Secret = secret,
EnabledAt = SystemClock.Instance.GetCurrentInstant(),
}.HashSecret();
break;
default:
throw new ArgumentOutOfRangeException(nameof(type), type, null);
}
if (factor is null) throw new InvalidOperationException("Unable to create auth factor.");
factor.AccountId = account.Id;
db.AccountAuthFactors.Add(factor);
await db.SaveChangesAsync();
return factor;
}
public async Task<AccountAuthFactor> EnableAuthFactor(AccountAuthFactor factor, string? code)
{
if (factor.EnabledAt is not null) throw new ArgumentException("The factor has been enabled.");
if (factor.Type is AccountAuthFactorType.Password or AccountAuthFactorType.TimedCode)
{
if (code is null || !factor.VerifyPassword(code))
throw new InvalidOperationException(
"Invalid code, you need to enter the correct code to enable the factor."
);
}
factor.EnabledAt = SystemClock.Instance.GetCurrentInstant();
db.Update(factor);
await db.SaveChangesAsync();
return factor;
}
public async Task<AccountAuthFactor> DisableAuthFactor(AccountAuthFactor factor)
{
if (factor.EnabledAt is null) throw new ArgumentException("The factor has been disabled.");
var count = await db.AccountAuthFactors
.Where(f => f.AccountId == factor.AccountId && f.EnabledAt != null)
.CountAsync();
if (count <= 1)
throw new InvalidOperationException(
"Disabling this auth factor will cause you have no active auth factors.");
factor.EnabledAt = null;
db.Update(factor);
await db.SaveChangesAsync();
return factor;
}
public async Task DeleteAuthFactor(AccountAuthFactor factor)
{
var count = await db.AccountAuthFactors
.Where(f => f.AccountId == factor.AccountId)
.If(factor.EnabledAt is not null, q => q.Where(f => f.EnabledAt != null))
.CountAsync();
if (count <= 1)
throw new InvalidOperationException("Deleting this auth factor will cause you have no auth factor.");
db.AccountAuthFactors.Remove(factor);
await db.SaveChangesAsync();
}
/// <summary>
/// Send the auth factor verification code to users, for factors like in-app code and email.
/// Sometimes it requires a hint, like a part of the user's email address to ensure the user is who own the account.
/// </summary>
/// <param name="account">The owner of the auth factor</param>
/// <param name="factor">The auth factor needed to send code</param>
/// <param name="hint">The part of the contact method for verification</param>
public async Task SendFactorCode(Account account, AccountAuthFactor factor, string? hint = null)
{
var code = new Random().Next(100000, 999999).ToString("000000");
switch (factor.Type)
{
case AccountAuthFactorType.InAppCode:
if (await _GetFactorCode(factor) is not null)
throw new InvalidOperationException("A factor code has been sent and in active duration.");
await nty.SendNotification(
account,
"auth.verification",
localizer["AuthCodeTitle"],
null,
localizer["AuthCodeBody", code],
save: true
);
await _SetFactorCode(factor, code, TimeSpan.FromMinutes(5));
break;
case AccountAuthFactorType.EmailCode:
if (await _GetFactorCode(factor) is not null)
throw new InvalidOperationException("A factor code has been sent and in active duration.");
ArgumentNullException.ThrowIfNull(hint);
hint = hint.Replace("@", "").Replace(".", "").Replace("+", "").Replace("%", "");
if (string.IsNullOrWhiteSpace(hint))
{
logger.LogWarning(
"Unable to send factor code to #{FactorId} with hint {Hint}, due to invalid hint...",
factor.Id,
hint
);
return;
}
var contact = await db.AccountContacts
.Where(c => c.Type == AccountContactType.Email)
.Where(c => c.VerifiedAt != null)
.Where(c => EF.Functions.ILike(c.Content, $"%{hint}%"))
.Include(c => c.Account)
.FirstOrDefaultAsync();
if (contact is null)
{
logger.LogWarning(
"Unable to send factor code to #{FactorId} with hint {Hint}, due to no contact method found according to hint...",
factor.Id,
hint
);
return;
}
await mailer.SendTemplatedEmailAsync<DysonNetwork.Sphere.Pages.Emails.VerificationEmail, VerificationEmailModel>(
account.Nick,
contact.Content,
localizer["VerificationEmail"],
new VerificationEmailModel
{
Name = account.Name,
Code = code
}
);
await _SetFactorCode(factor, code, TimeSpan.FromMinutes(30));
break;
case AccountAuthFactorType.Password:
case AccountAuthFactorType.TimedCode:
default:
// No need to send, such as password etc...
return;
}
}
public async Task<bool> VerifyFactorCode(AccountAuthFactor factor, string code)
{
switch (factor.Type)
{
case AccountAuthFactorType.EmailCode:
case AccountAuthFactorType.InAppCode:
var correctCode = await _GetFactorCode(factor);
var isCorrect = correctCode is not null &&
string.Equals(correctCode, code, StringComparison.OrdinalIgnoreCase);
await cache.RemoveAsync($"{AuthFactorCachePrefix}{factor.Id}:code");
return isCorrect;
case AccountAuthFactorType.Password:
case AccountAuthFactorType.TimedCode:
default:
return factor.VerifyPassword(code);
}
}
private const string AuthFactorCachePrefix = "authfactor:";
private async Task _SetFactorCode(AccountAuthFactor factor, string code, TimeSpan expires)
{
await cache.SetAsync(
$"{AuthFactorCachePrefix}{factor.Id}:code",
code,
expires
);
}
private async Task<string?> _GetFactorCode(AccountAuthFactor factor)
{
return await cache.GetAsync<string?>(
$"{AuthFactorCachePrefix}{factor.Id}:code"
);
}
public async Task<Session> UpdateSessionLabel(Account account, Guid sessionId, string label)
{
var session = await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.Id == sessionId && s.AccountId == account.Id)
.FirstOrDefaultAsync();
if (session is null) throw new InvalidOperationException("Session was not found.");
await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId)
.ExecuteUpdateAsync(p => p.SetProperty(s => s.Label, label));
var sessions = await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId)
.ToListAsync();
foreach (var item in sessions)
await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}");
return session;
}
public async Task DeleteSession(Account account, Guid sessionId)
{
var session = await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.Id == sessionId && s.AccountId == account.Id)
.FirstOrDefaultAsync();
if (session is null) throw new InvalidOperationException("Session was not found.");
var sessions = await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId)
.ToListAsync();
if (session.Challenge.DeviceId is not null)
await nty.UnsubscribePushNotifications(session.Challenge.DeviceId);
// The current session should be included in the sessions' list
await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId)
.ExecuteDeleteAsync();
foreach (var item in sessions)
await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}");
}
public async Task<AccountContact> CreateContactMethod(Account account, AccountContactType type, string content)
{
var contact = new AccountContact
{
Type = type,
Content = content,
AccountId = account.Id,
};
db.AccountContacts.Add(contact);
await db.SaveChangesAsync();
return contact;
}
public async Task VerifyContactMethod(Account account, AccountContact contact)
{
var spell = await spells.CreateMagicSpell(
account,
MagicSpellType.ContactVerification,
new Dictionary<string, object> { { "contact_method", contact.Content } },
expiredAt: SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
preventRepeat: true
);
await spells.NotifyMagicSpell(spell);
}
public async Task<AccountContact> SetContactMethodPrimary(Account account, AccountContact contact)
{
if (contact.AccountId != account.Id)
throw new InvalidOperationException("Contact method does not belong to this account.");
if (contact.VerifiedAt is null)
throw new InvalidOperationException("Cannot set unverified contact method as primary.");
await using var transaction = await db.Database.BeginTransactionAsync();
try
{
await db.AccountContacts
.Where(c => c.AccountId == account.Id && c.Type == contact.Type)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.IsPrimary, false));
contact.IsPrimary = true;
db.AccountContacts.Update(contact);
await db.SaveChangesAsync();
await transaction.CommitAsync();
return contact;
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
public async Task DeleteContactMethod(Account account, AccountContact contact)
{
if (contact.AccountId != account.Id)
throw new InvalidOperationException("Contact method does not belong to this account.");
if (contact.IsPrimary)
throw new InvalidOperationException("Cannot delete primary contact method.");
db.AccountContacts.Remove(contact);
await db.SaveChangesAsync();
}
/// <summary>
/// This method will grant a badge to the account.
/// Shouldn't be exposed to normal user and the user itself.
/// </summary>
public async Task<Badge> GrantBadge(Account account, Badge badge)
{
badge.AccountId = account.Id;
db.Badges.Add(badge);
await db.SaveChangesAsync();
return badge;
}
/// <summary>
/// This method will revoke a badge from the account.
/// Shouldn't be exposed to normal user and the user itself.
/// </summary>
public async Task RevokeBadge(Account account, Guid badgeId)
{
var badge = await db.Badges
.Where(b => b.AccountId == account.Id && b.Id == badgeId)
.OrderByDescending(b => b.CreatedAt)
.FirstOrDefaultAsync();
if (badge is null) throw new InvalidOperationException("Badge was not found.");
var profile = await db.AccountProfiles
.Where(p => p.AccountId == account.Id)
.FirstOrDefaultAsync();
if (profile?.ActiveBadge is not null && profile.ActiveBadge.Id == badge.Id)
profile.ActiveBadge = null;
db.Remove(badge);
await db.SaveChangesAsync();
}
public async Task ActiveBadge(Account account, Guid badgeId)
{
await using var transaction = await db.Database.BeginTransactionAsync();
try
{
var badge = await db.Badges
.Where(b => b.AccountId == account.Id && b.Id == badgeId)
.OrderByDescending(b => b.CreatedAt)
.FirstOrDefaultAsync();
if (badge is null) throw new InvalidOperationException("Badge was not found.");
await db.Badges
.Where(b => b.AccountId == account.Id && b.Id != badgeId)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.ActivatedAt, p => null));
badge.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(badge);
await db.SaveChangesAsync();
await db.AccountProfiles
.Where(p => p.AccountId == account.Id)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.ActiveBadge, badge.ToReference()));
await PurgeAccountCache(account);
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
/// <summary>
/// The maintenance method for server administrator.
/// To check every user has an account profile and to create them if it isn't having one.
/// </summary>
public async Task EnsureAccountProfileCreated()
{
var accountsId = await db.Accounts.Select(a => a.Id).ToListAsync();
var existingId = await db.AccountProfiles.Select(p => p.AccountId).ToListAsync();
var missingId = accountsId.Except(existingId).ToList();
if (missingId.Count != 0)
{
var newProfiles = missingId.Select(id => new Profile { Id = Guid.NewGuid(), AccountId = id }).ToList();
await db.BulkInsertAsync(newProfiles);
}
}
}

View File

@ -1,105 +0,0 @@
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Account;
/// <summary>
/// Service for handling username generation and validation
/// </summary>
public class AccountUsernameService(AppDatabase db)
{
private readonly Random _random = new();
/// <summary>
/// Generates a unique username based on the provided base name
/// </summary>
/// <param name="baseName">The preferred username</param>
/// <returns>A unique username</returns>
public async Task<string> GenerateUniqueUsernameAsync(string baseName)
{
// Sanitize the base name
var sanitized = SanitizeUsername(baseName);
// If the base name is empty after sanitization, use a default
if (string.IsNullOrEmpty(sanitized))
{
sanitized = "user";
}
// Check if the sanitized name is available
if (!await IsUsernameExistsAsync(sanitized))
{
return sanitized;
}
// Try up to 10 times with random numbers
for (int i = 0; i < 10; i++)
{
var suffix = _random.Next(1000, 9999);
var candidate = $"{sanitized}{suffix}";
if (!await IsUsernameExistsAsync(candidate))
{
return candidate;
}
}
// If all attempts fail, use a timestamp
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
return $"{sanitized}{timestamp}";
}
/// <summary>
/// Sanitizes a username by removing invalid characters and converting to lowercase
/// </summary>
public string SanitizeUsername(string username)
{
if (string.IsNullOrEmpty(username))
return string.Empty;
// Replace spaces and special characters with underscores
var sanitized = Regex.Replace(username, @"[^a-zA-Z0-9_\-]", "");
// Convert to lowercase
sanitized = sanitized.ToLowerInvariant();
// Ensure it starts with a letter
if (sanitized.Length > 0 && !char.IsLetter(sanitized[0]))
{
sanitized = "u" + sanitized;
}
// Truncate if too long
if (sanitized.Length > 30)
{
sanitized = sanitized[..30];
}
return sanitized;
}
/// <summary>
/// Checks if a username already exists
/// </summary>
public async Task<bool> IsUsernameExistsAsync(string username)
{
return await db.Accounts.AnyAsync(a => a.Name == username);
}
/// <summary>
/// Generates a username from an email address
/// </summary>
/// <param name="email">The email address to generate a username from</param>
/// <returns>A unique username derived from the email</returns>
public async Task<string> GenerateUsernameFromEmailAsync(string email)
{
if (string.IsNullOrEmpty(email))
return await GenerateUniqueUsernameAsync("user");
// Extract the local part of the email (before the @)
var localPart = email.Split('@')[0];
// Use the local part as the base for username generation
return await GenerateUniqueUsernameAsync(localPart);
}
}

View File

@ -1,58 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Point = NetTopologySuite.Geometries.Point;
namespace DysonNetwork.Sphere.Account;
public abstract class ActionLogType
{
public const string NewLogin = "login";
public const string ChallengeAttempt = "challenges.attempt";
public const string ChallengeSuccess = "challenges.success";
public const string ChallengeFailure = "challenges.failure";
public const string PostCreate = "posts.create";
public const string PostUpdate = "posts.update";
public const string PostDelete = "posts.delete";
public const string PostReact = "posts.react";
public const string MessageCreate = "messages.create";
public const string MessageUpdate = "messages.update";
public const string MessageDelete = "messages.delete";
public const string MessageReact = "messages.react";
public const string PublisherCreate = "publishers.create";
public const string PublisherUpdate = "publishers.update";
public const string PublisherDelete = "publishers.delete";
public const string PublisherMemberInvite = "publishers.members.invite";
public const string PublisherMemberJoin = "publishers.members.join";
public const string PublisherMemberLeave = "publishers.members.leave";
public const string PublisherMemberKick = "publishers.members.kick";
public const string RealmCreate = "realms.create";
public const string RealmUpdate = "realms.update";
public const string RealmDelete = "realms.delete";
public const string RealmInvite = "realms.invite";
public const string RealmJoin = "realms.join";
public const string RealmLeave = "realms.leave";
public const string RealmKick = "realms.kick";
public const string RealmAdjustRole = "realms.role.edit";
public const string ChatroomCreate = "chatrooms.create";
public const string ChatroomUpdate = "chatrooms.update";
public const string ChatroomDelete = "chatrooms.delete";
public const string ChatroomInvite = "chatrooms.invite";
public const string ChatroomJoin = "chatrooms.join";
public const string ChatroomLeave = "chatrooms.leave";
public const string ChatroomKick = "chatrooms.kick";
public const string ChatroomAdjustRole = "chatrooms.role.edit";
}
public class ActionLog : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(4096)] public string Action { get; set; } = null!;
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
[MaxLength(512)] public string? UserAgent { get; set; }
[MaxLength(128)] public string? IpAddress { get; set; }
public Point? Location { get; set; }
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
public Guid? SessionId { get; set; }
}

View File

@ -1,46 +0,0 @@
using Quartz;
using DysonNetwork.Sphere.Connection;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Storage.Handlers;
namespace DysonNetwork.Sphere.Account;
public class ActionLogService(GeoIpService geo, FlushBufferService fbs)
{
public void CreateActionLog(Guid accountId, string action, Dictionary<string, object> meta)
{
var log = new ActionLog
{
Action = action,
AccountId = accountId,
Meta = meta,
};
fbs.Enqueue(log);
}
public void CreateActionLogFromRequest(string action, Dictionary<string, object> meta, HttpRequest request,
Account? account = null)
{
var log = new ActionLog
{
Action = action,
Meta = meta,
UserAgent = request.Headers.UserAgent,
IpAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString(),
Location = geo.GetPointFromIp(request.HttpContext.Connection.RemoteIpAddress?.ToString())
};
if (request.HttpContext.Items["CurrentUser"] is Account currentUser)
log.AccountId = currentUser.Id;
else if (account != null)
log.AccountId = account.Id;
else
throw new ArgumentException("No user context was found");
if (request.HttpContext.Items["CurrentSession"] is Auth.Session currentSession)
log.SessionId = currentSession.Id;
fbs.Enqueue(log);
}
}

View File

@ -1,47 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public class Badge : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Type { get; set; } = null!;
[MaxLength(1024)] public string? Label { get; set; }
[MaxLength(4096)] public string? Caption { get; set; }
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
public Instant? ActivatedAt { get; set; }
public Instant? ExpiredAt { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!;
public BadgeReferenceObject ToReference()
{
return new BadgeReferenceObject
{
Id = Id,
Type = Type,
Label = Label,
Caption = Caption,
Meta = Meta,
ActivatedAt = ActivatedAt,
ExpiredAt = ExpiredAt,
AccountId = AccountId
};
}
}
public class BadgeReferenceObject : ModelBase
{
public Guid Id { get; set; }
public string Type { get; set; } = null!;
public string? Label { get; set; }
public string? Caption { get; set; }
public Dictionary<string, object>? Meta { get; set; }
public Instant? ActivatedAt { get; set; }
public Instant? ExpiredAt { get; set; }
public Guid AccountId { get; set; }
}

View File

@ -1,65 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public enum StatusAttitude
{
Positive,
Negative,
Neutral
}
public class Status : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public StatusAttitude Attitude { get; set; }
[NotMapped] public bool IsOnline { get; set; }
[NotMapped] public bool IsCustomized { get; set; } = true;
public bool IsInvisible { get; set; }
public bool IsNotDisturb { get; set; }
[MaxLength(1024)] public string? Label { get; set; }
public Instant? ClearedAt { get; set; }
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
}
public enum CheckInResultLevel
{
Worst,
Worse,
Normal,
Better,
Best
}
public class CheckInResult : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public CheckInResultLevel Level { get; set; }
public decimal? RewardPoints { get; set; }
public int? RewardExperience { get; set; }
[Column(TypeName = "jsonb")] public ICollection<FortuneTip> Tips { get; set; } = new List<FortuneTip>();
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
}
public class FortuneTip
{
public bool IsPositive { get; set; }
public string Title { get; set; } = null!;
public string Content { get; set; } = null!;
}
/// <summary>
/// This method should not be mapped. Used to generate the daily event calendar.
/// </summary>
public class DailyEventResponse
{
public Instant Date { get; set; }
public CheckInResult? CheckInResult { get; set; }
public ICollection<Status> Statuses { get; set; } = new List<Status>();
}

View File

@ -1,30 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public enum MagicSpellType
{
AccountActivation,
AccountDeactivation,
AccountRemoval,
AuthPasswordReset,
ContactVerification,
}
[Index(nameof(Spell), IsUnique = true)]
public class MagicSpell : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[JsonIgnore] [MaxLength(1024)] public string Spell { get; set; } = null!;
public MagicSpellType Type { get; set; }
public Instant? ExpiresAt { get; set; }
public Instant? AffectedAt { get; set; }
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
public Guid? AccountId { get; set; }
public Account? Account { get; set; }
}

View File

@ -1,19 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Account;
[ApiController]
[Route("/spells")]
public class MagicSpellController(AppDatabase db, MagicSpellService sp) : ControllerBase
{
[HttpPost("{spellId:guid}/resend")]
public async Task<ActionResult> ResendMagicSpell(Guid spellId)
{
var spell = db.MagicSpells.FirstOrDefault(x => x.Id == spellId);
if (spell == null)
return NotFound();
await sp.NotifyMagicSpell(spell, true);
return Ok();
}
}

View File

@ -1,252 +0,0 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
using DysonNetwork.Sphere.Email;
using DysonNetwork.Sphere.Pages.Emails;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Resources.Localization;
using DysonNetwork.Sphere.Resources.Pages.Emails;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public class MagicSpellService(
AppDatabase db,
EmailService email,
IConfiguration configuration,
ILogger<MagicSpellService> logger,
IStringLocalizer<Localization.EmailResource> localizer
)
{
public async Task<MagicSpell> CreateMagicSpell(
Account account,
MagicSpellType type,
Dictionary<string, object> meta,
Instant? expiredAt = null,
Instant? affectedAt = null,
bool preventRepeat = false
)
{
if (preventRepeat)
{
var now = SystemClock.Instance.GetCurrentInstant();
var existingSpell = await db.MagicSpells
.Where(s => s.AccountId == account.Id)
.Where(s => s.Type == type)
.Where(s => s.ExpiresAt == null || s.ExpiresAt > now)
.FirstOrDefaultAsync();
if (existingSpell != null)
{
throw new InvalidOperationException($"Account already has an active magic spell of type {type}");
}
}
var spellWord = _GenerateRandomString(128);
var spell = new MagicSpell
{
Spell = spellWord,
Type = type,
ExpiresAt = expiredAt,
AffectedAt = affectedAt,
AccountId = account.Id,
Meta = meta
};
db.MagicSpells.Add(spell);
await db.SaveChangesAsync();
return spell;
}
public async Task NotifyMagicSpell(MagicSpell spell, bool bypassVerify = false)
{
var contact = await db.AccountContacts
.Where(c => c.Account.Id == spell.AccountId)
.Where(c => c.Type == AccountContactType.Email)
.Where(c => c.VerifiedAt != null || bypassVerify)
.OrderByDescending(c => c.IsPrimary)
.Include(c => c.Account)
.FirstOrDefaultAsync();
if (contact is null) throw new ArgumentException("Account has no contact method that can use");
var link = $"{configuration.GetValue<string>("BaseUrl")}/spells/{Uri.EscapeDataString(spell.Spell)}";
logger.LogInformation("Sending magic spell... {Link}", link);
var accountLanguage = await db.Accounts
.Where(a => a.Id == spell.AccountId)
.Select(a => a.Language)
.FirstOrDefaultAsync();
AccountService.SetCultureInfo(accountLanguage);
try
{
switch (spell.Type)
{
case MagicSpellType.AccountActivation:
await email.SendTemplatedEmailAsync<LandingEmail, LandingEmailModel>(
contact.Account.Nick,
contact.Content,
localizer["EmailLandingTitle"],
new LandingEmailModel
{
Name = contact.Account.Name,
Link = link
}
);
break;
case MagicSpellType.AccountRemoval:
await email.SendTemplatedEmailAsync<AccountDeletionEmail, AccountDeletionEmailModel>(
contact.Account.Nick,
contact.Content,
localizer["EmailAccountDeletionTitle"],
new AccountDeletionEmailModel
{
Name = contact.Account.Name,
Link = link
}
);
break;
case MagicSpellType.AuthPasswordReset:
await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>(
contact.Account.Nick,
contact.Content,
localizer["EmailAccountDeletionTitle"],
new PasswordResetEmailModel
{
Name = contact.Account.Name,
Link = link
}
);
break;
case MagicSpellType.ContactVerification:
if (spell.Meta["contact_method"] is not string contactMethod)
throw new InvalidOperationException("Contact method is not found.");
await email.SendTemplatedEmailAsync<ContactVerificationEmail, ContactVerificationEmailModel>(
contact.Account.Nick,
contactMethod!,
localizer["EmailContactVerificationTitle"],
new ContactVerificationEmailModel
{
Name = contact.Account.Name,
Link = link
}
);
break;
default:
throw new ArgumentOutOfRangeException();
}
}
catch (Exception err)
{
logger.LogError($"Error sending magic spell (${spell.Spell})... {err}");
}
}
public async Task ApplyMagicSpell(MagicSpell spell)
{
switch (spell.Type)
{
case MagicSpellType.AuthPasswordReset:
throw new ArgumentException(
"For password reset spell, please use the ApplyPasswordReset method instead."
);
case MagicSpellType.AccountRemoval:
var account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId);
if (account is null) break;
db.Accounts.Remove(account);
break;
case MagicSpellType.AccountActivation:
var contactMethod = (spell.Meta["contact_method"] as JsonElement? ?? default).ToString();
var contact = await
db.AccountContacts.FirstOrDefaultAsync(c =>
c.Content == contactMethod
);
if (contact is not null)
{
contact.VerifiedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(contact);
}
account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId);
if (account is not null)
{
account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(account);
}
var defaultGroup = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Key == "default");
if (defaultGroup is not null && account is not null)
{
db.PermissionGroupMembers.Add(new PermissionGroupMember
{
Actor = $"user:{account.Id}",
Group = defaultGroup
});
}
break;
case MagicSpellType.ContactVerification:
var verifyContactMethod = (spell.Meta["contact_method"] as JsonElement? ?? default).ToString();
var verifyContact = await db.AccountContacts
.FirstOrDefaultAsync(c => c.Content == verifyContactMethod);
if (verifyContact is not null)
{
verifyContact.VerifiedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(verifyContact);
}
break;
default:
throw new ArgumentOutOfRangeException();
}
db.Remove(spell);
await db.SaveChangesAsync();
}
public async Task ApplyPasswordReset(MagicSpell spell, string newPassword)
{
if (spell.Type != MagicSpellType.AuthPasswordReset)
throw new ArgumentException("This spell is not a password reset spell.");
var passwordFactor = await db.AccountAuthFactors
.Include(f => f.Account)
.Where(f => f.Type == AccountAuthFactorType.Password && f.Account.Id == spell.AccountId)
.FirstOrDefaultAsync();
if (passwordFactor is null)
{
var account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId);
if (account is null) throw new InvalidOperationException("Both account and auth factor was not found.");
passwordFactor = new AccountAuthFactor
{
Type = AccountAuthFactorType.Password,
Account = account,
Secret = newPassword
}.HashSecret();
db.AccountAuthFactors.Add(passwordFactor);
}
else
{
passwordFactor.Secret = newPassword;
passwordFactor.HashSecret();
db.Update(passwordFactor);
}
await db.SaveChangesAsync();
}
private static string _GenerateRandomString(int length)
{
using var rng = RandomNumberGenerator.Create();
var randomBytes = new byte[length];
rng.GetBytes(randomBytes);
var base64String = Convert.ToBase64String(randomBytes);
return base64String.Substring(0, length);
}
}

View File

@ -1,41 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public class Notification : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Topic { get; set; } = null!;
[MaxLength(1024)] public string? Title { get; set; }
[MaxLength(2048)] public string? Subtitle { get; set; }
[MaxLength(4096)] public string? Content { get; set; }
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; }
public int Priority { get; set; } = 10;
public Instant? ViewedAt { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!;
}
public enum NotificationPushProvider
{
Apple,
Google
}
[Index(nameof(DeviceToken), nameof(DeviceId), nameof(AccountId), IsUnique = true)]
public class NotificationPushSubscription : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(4096)] public string DeviceId { get; set; } = null!;
[MaxLength(4096)] public string DeviceToken { get; set; } = null!;
public NotificationPushProvider Provider { get; set; }
public Instant? LastUsedAt { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account Account { get; set; } = null!;
}

View File

@ -1,166 +0,0 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Permission;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
[ApiController]
[Route("/notifications")]
public class NotificationController(AppDatabase db, NotificationService nty) : ControllerBase
{
[HttpGet("count")]
[Authorize]
public async Task<ActionResult<int>> CountUnreadNotifications()
{
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
if (currentUserValue is not Account currentUser) return Unauthorized();
var count = await db.Notifications
.Where(s => s.AccountId == currentUser.Id && s.ViewedAt == null)
.CountAsync();
return Ok(count);
}
[HttpGet]
[Authorize]
public async Task<ActionResult<List<Notification>>> ListNotifications(
[FromQuery] int offset = 0,
// The page size set to 5 is to avoid the client pulled the notification
// but didn't render it in the screen-viewable region.
[FromQuery] int take = 5
)
{
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
if (currentUserValue is not Account currentUser) return Unauthorized();
var totalCount = await db.Notifications
.Where(s => s.AccountId == currentUser.Id)
.CountAsync();
var notifications = await db.Notifications
.Where(s => s.AccountId == currentUser.Id)
.OrderByDescending(e => e.CreatedAt)
.Skip(offset)
.Take(take)
.ToListAsync();
Response.Headers["X-Total"] = totalCount.ToString();
await nty.MarkNotificationsViewed(notifications);
return Ok(notifications);
}
public class PushNotificationSubscribeRequest
{
[MaxLength(4096)] public string DeviceToken { get; set; } = null!;
public NotificationPushProvider Provider { get; set; }
}
[HttpPut("subscription")]
[Authorize]
public async Task<ActionResult<NotificationPushSubscription>> SubscribeToPushNotification(
[FromBody] PushNotificationSubscribeRequest request
)
{
HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue);
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account;
if (currentUser == null) return Unauthorized();
var currentSession = currentSessionValue as Session;
if (currentSession == null) return Unauthorized();
var result =
await nty.SubscribePushNotification(currentUser, request.Provider, currentSession.Challenge.DeviceId!,
request.DeviceToken);
return Ok(result);
}
[HttpDelete("subscription")]
[Authorize]
public async Task<ActionResult<int>> UnsubscribeFromPushNotification()
{
HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue);
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account;
if (currentUser == null) return Unauthorized();
var currentSession = currentSessionValue as Session;
if (currentSession == null) return Unauthorized();
var affectedRows = await db.NotificationPushSubscriptions
.Where(s =>
s.AccountId == currentUser.Id &&
s.DeviceId == currentSession.Challenge.DeviceId
).ExecuteDeleteAsync();
return Ok(affectedRows);
}
public class NotificationRequest
{
[Required] [MaxLength(1024)] public string Topic { get; set; } = null!;
[Required] [MaxLength(1024)] public string Title { get; set; } = null!;
[MaxLength(2048)] public string? Subtitle { get; set; }
[Required] [MaxLength(4096)] public string Content { get; set; } = null!;
public Dictionary<string, object>? Meta { get; set; }
public int Priority { get; set; } = 10;
}
[HttpPost("broadcast")]
[Authorize]
[RequiredPermission("global", "notifications.broadcast")]
public async Task<ActionResult> BroadcastNotification(
[FromBody] NotificationRequest request,
[FromQuery] bool save = false
)
{
await nty.BroadcastNotification(
new Notification
{
CreatedAt = SystemClock.Instance.GetCurrentInstant(),
UpdatedAt = SystemClock.Instance.GetCurrentInstant(),
Topic = request.Topic,
Title = request.Title,
Subtitle = request.Subtitle,
Content = request.Content,
Meta = request.Meta,
Priority = request.Priority,
},
save
);
return Ok();
}
public class NotificationWithAimRequest : NotificationRequest
{
[Required] public List<Guid> AccountId { get; set; } = null!;
}
[HttpPost("send")]
[Authorize]
[RequiredPermission("global", "notifications.send")]
public async Task<ActionResult> SendNotification(
[FromBody] NotificationWithAimRequest request,
[FromQuery] bool save = false
)
{
var accounts = await db.Accounts.Where(a => request.AccountId.Contains(a.Id)).ToListAsync();
await nty.SendNotificationBatch(
new Notification
{
CreatedAt = SystemClock.Instance.GetCurrentInstant(),
UpdatedAt = SystemClock.Instance.GetCurrentInstant(),
Topic = request.Topic,
Title = request.Title,
Subtitle = request.Subtitle,
Content = request.Content,
Meta = request.Meta,
},
accounts,
save
);
return Ok();
}
}

View File

@ -1,308 +0,0 @@
using System.Text;
using System.Text.Json;
using DysonNetwork.Sphere.Connection;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public class NotificationService(
AppDatabase db,
WebSocketService ws,
IHttpClientFactory httpFactory,
IConfiguration config)
{
private readonly string _notifyTopic = config["Notifications:Topic"]!;
private readonly Uri _notifyEndpoint = new(config["Notifications:Endpoint"]!);
public async Task UnsubscribePushNotifications(string deviceId)
{
await db.NotificationPushSubscriptions
.Where(s => s.DeviceId == deviceId)
.ExecuteDeleteAsync();
}
public async Task<NotificationPushSubscription> SubscribePushNotification(
Account account,
NotificationPushProvider provider,
string deviceId,
string deviceToken
)
{
var now = SystemClock.Instance.GetCurrentInstant();
// First check if a matching subscription exists
var existingSubscription = await db.NotificationPushSubscriptions
.Where(s => s.AccountId == account.Id)
.Where(s => s.DeviceId == deviceId || s.DeviceToken == deviceToken)
.FirstOrDefaultAsync();
if (existingSubscription is not null)
{
// Update the existing subscription directly in the database
await db.NotificationPushSubscriptions
.Where(s => s.Id == existingSubscription.Id)
.ExecuteUpdateAsync(setters => setters
.SetProperty(s => s.DeviceId, deviceId)
.SetProperty(s => s.DeviceToken, deviceToken)
.SetProperty(s => s.UpdatedAt, now));
// Return the updated subscription
existingSubscription.DeviceId = deviceId;
existingSubscription.DeviceToken = deviceToken;
existingSubscription.UpdatedAt = now;
return existingSubscription;
}
var subscription = new NotificationPushSubscription
{
DeviceId = deviceId,
DeviceToken = deviceToken,
Provider = provider,
AccountId = account.Id,
};
db.NotificationPushSubscriptions.Add(subscription);
await db.SaveChangesAsync();
return subscription;
}
public async Task<Notification> SendNotification(
Account account,
string topic,
string? title = null,
string? subtitle = null,
string? content = null,
Dictionary<string, object>? meta = null,
string? actionUri = null,
bool isSilent = false,
bool save = true
)
{
if (title is null && subtitle is null && content is null)
throw new ArgumentException("Unable to send notification that completely empty.");
meta ??= new Dictionary<string, object>();
if (actionUri is not null) meta["action_uri"] = actionUri;
var notification = new Notification
{
Topic = topic,
Title = title,
Subtitle = subtitle,
Content = content,
Meta = meta,
AccountId = account.Id,
};
if (save)
{
db.Add(notification);
await db.SaveChangesAsync();
}
if (!isSilent) _ = DeliveryNotification(notification);
return notification;
}
public async Task DeliveryNotification(Notification notification)
{
ws.SendPacketToAccount(notification.AccountId, new WebSocketPacket
{
Type = "notifications.new",
Data = notification
});
// Pushing the notification
var subscribers = await db.NotificationPushSubscriptions
.Where(s => s.AccountId == notification.AccountId)
.ToListAsync();
await _PushNotification(notification, subscribers);
}
public async Task MarkNotificationsViewed(ICollection<Notification> notifications)
{
var now = SystemClock.Instance.GetCurrentInstant();
var id = notifications.Where(n => n.ViewedAt == null).Select(n => n.Id).ToList();
if (id.Count == 0) return;
await db.Notifications
.Where(n => id.Contains(n.Id))
.ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now)
);
}
public async Task BroadcastNotification(Notification notification, bool save = false)
{
var accounts = await db.Accounts.ToListAsync();
if (save)
{
var notifications = accounts.Select(x =>
{
var newNotification = new Notification
{
Topic = notification.Topic,
Title = notification.Title,
Subtitle = notification.Subtitle,
Content = notification.Content,
Meta = notification.Meta,
Priority = notification.Priority,
Account = x,
AccountId = x.Id
};
return newNotification;
}).ToList();
await db.BulkInsertAsync(notifications);
}
foreach (var account in accounts)
{
notification.Account = account;
notification.AccountId = account.Id;
ws.SendPacketToAccount(account.Id, new WebSocketPacket
{
Type = "notifications.new",
Data = notification
});
}
var subscribers = await db.NotificationPushSubscriptions
.ToListAsync();
await _PushNotification(notification, subscribers);
}
public async Task SendNotificationBatch(Notification notification, List<Account> accounts, bool save = false)
{
if (save)
{
var notifications = accounts.Select(x =>
{
var newNotification = new Notification
{
Topic = notification.Topic,
Title = notification.Title,
Subtitle = notification.Subtitle,
Content = notification.Content,
Meta = notification.Meta,
Priority = notification.Priority,
Account = x,
AccountId = x.Id
};
return newNotification;
}).ToList();
await db.BulkInsertAsync(notifications);
}
foreach (var account in accounts)
{
notification.Account = account;
notification.AccountId = account.Id;
ws.SendPacketToAccount(account.Id, new WebSocketPacket
{
Type = "notifications.new",
Data = notification
});
}
var accountsId = accounts.Select(x => x.Id).ToList();
var subscribers = await db.NotificationPushSubscriptions
.Where(s => accountsId.Contains(s.AccountId))
.ToListAsync();
await _PushNotification(notification, subscribers);
}
private List<Dictionary<string, object>> _BuildNotificationPayload(Notification notification,
IEnumerable<NotificationPushSubscription> subscriptions)
{
var subDict = subscriptions
.GroupBy(x => x.Provider)
.ToDictionary(x => x.Key, x => x.ToList());
var notifications = subDict.Select(value =>
{
var platformCode = value.Key switch
{
NotificationPushProvider.Apple => 1,
NotificationPushProvider.Google => 2,
_ => throw new InvalidOperationException($"Unknown push provider: {value.Key}")
};
var tokens = value.Value.Select(x => x.DeviceToken).ToList();
return _BuildNotificationPayload(notification, platformCode, tokens);
}).ToList();
return notifications.ToList();
}
private Dictionary<string, object> _BuildNotificationPayload(Notification notification, int platformCode,
IEnumerable<string> deviceTokens)
{
var alertDict = new Dictionary<string, object>();
var dict = new Dictionary<string, object>
{
["notif_id"] = notification.Id.ToString(),
["apns_id"] = notification.Id.ToString(),
["topic"] = _notifyTopic,
["tokens"] = deviceTokens,
["data"] = new Dictionary<string, object>
{
["type"] = notification.Topic,
["meta"] = notification.Meta ?? new Dictionary<string, object>(),
},
["mutable_content"] = true,
["priority"] = notification.Priority >= 5 ? "high" : "normal",
};
if (!string.IsNullOrWhiteSpace(notification.Title))
{
dict["title"] = notification.Title;
alertDict["title"] = notification.Title;
}
if (!string.IsNullOrWhiteSpace(notification.Content))
{
dict["message"] = notification.Content;
alertDict["body"] = notification.Content;
}
if (!string.IsNullOrWhiteSpace(notification.Subtitle))
{
dict["message"] = $"{notification.Subtitle}\n{dict["message"]}";
alertDict["subtitle"] = notification.Subtitle;
}
if (notification.Priority >= 5)
dict["name"] = "default";
dict["platform"] = platformCode;
dict["alert"] = alertDict;
return dict;
}
private async Task _PushNotification(Notification notification,
IEnumerable<NotificationPushSubscription> subscriptions)
{
var subList = subscriptions.ToList();
if (subList.Count == 0) return;
var requestDict = new Dictionary<string, object>
{
["notifications"] = _BuildNotificationPayload(notification, subList)
};
var client = httpFactory.CreateClient();
client.BaseAddress = _notifyEndpoint;
var request = await client.PostAsync("/push", new StringContent(
JsonSerializer.Serialize(requestDict),
Encoding.UTF8,
"application/json"
));
request.EnsureSuccessStatusCode();
}
}

View File

@ -1,22 +0,0 @@
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public enum RelationshipStatus : short
{
Friends = 100,
Pending = 0,
Blocked = -100
}
public class Relationship : ModelBase
{
public Guid AccountId { get; set; }
public Account Account { get; set; } = null!;
public Guid RelatedId { get; set; }
public Account Related { get; set; } = null!;
public Instant? ExpiredAt { get; set; }
public RelationshipStatus Status { get; set; } = RelationshipStatus.Pending;
}

View File

@ -1,253 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
[ApiController]
[Route("/relationships")]
public class RelationshipController(AppDatabase db, RelationshipService rels) : ControllerBase
{
[HttpGet]
[Authorize]
public async Task<ActionResult<List<Relationship>>> ListRelationships([FromQuery] int offset = 0,
[FromQuery] int take = 20)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var query = db.AccountRelationships.AsQueryable()
.Where(r => r.RelatedId == userId);
var totalCount = await query.CountAsync();
var relationships = await query
.Include(r => r.Related)
.Include(r => r.Related.Profile)
.Include(r => r.Account)
.Include(r => r.Account.Profile)
.Skip(offset)
.Take(take)
.ToListAsync();
var statuses = await db.AccountRelationships
.Where(r => r.AccountId == userId)
.ToDictionaryAsync(r => r.RelatedId);
foreach (var relationship in relationships)
if (statuses.TryGetValue(relationship.RelatedId, out var status))
relationship.Status = status.Status;
Response.Headers["X-Total"] = totalCount.ToString();
return relationships;
}
[HttpGet("requests")]
[Authorize]
public async Task<ActionResult<List<Relationship>>> ListSentRequests()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var relationships = await db.AccountRelationships
.Where(r => r.AccountId == currentUser.Id && r.Status == RelationshipStatus.Pending)
.Include(r => r.Related)
.Include(r => r.Related.Profile)
.Include(r => r.Account)
.Include(r => r.Account.Profile)
.ToListAsync();
return relationships;
}
public class RelationshipRequest
{
[Required] public RelationshipStatus Status { get; set; }
}
[HttpPost("{userId:guid}")]
[Authorize]
public async Task<ActionResult<Relationship>> CreateRelationship(Guid userId,
[FromBody] RelationshipRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var relatedUser = await db.Accounts.FindAsync(userId);
if (relatedUser is null) return NotFound("Account was not found.");
try
{
var relationship = await rels.CreateRelationship(
currentUser, relatedUser, request.Status
);
return relationship;
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
}
[HttpPatch("{userId:guid}")]
[Authorize]
public async Task<ActionResult<Relationship>> UpdateRelationship(Guid userId,
[FromBody] RelationshipRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
try
{
var relationship = await rels.UpdateRelationship(currentUser.Id, userId, request.Status);
return relationship;
}
catch (ArgumentException err)
{
return NotFound(err.Message);
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
}
[HttpGet("{userId:guid}")]
[Authorize]
public async Task<ActionResult<Relationship>> GetRelationship(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
var queries = db.AccountRelationships.AsQueryable()
.Where(r => r.AccountId == currentUser.Id && r.RelatedId == userId)
.Where(r => r.ExpiredAt == null || r.ExpiredAt > now);
var relationship = await queries
.Include(r => r.Related)
.Include(r => r.Related.Profile)
.FirstOrDefaultAsync();
if (relationship is null) return NotFound();
relationship.Account = currentUser;
return Ok(relationship);
}
[HttpPost("{userId:guid}/friends")]
[Authorize]
public async Task<ActionResult<Relationship>> SendFriendRequest(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var relatedUser = await db.Accounts.FindAsync(userId);
if (relatedUser is null) return NotFound("Account was not found.");
var existing = await db.AccountRelationships.FirstOrDefaultAsync(r =>
(r.AccountId == currentUser.Id && r.RelatedId == userId) ||
(r.AccountId == userId && r.RelatedId == currentUser.Id));
if (existing != null) return BadRequest("Relationship already exists.");
try
{
var relationship = await rels.SendFriendRequest(currentUser, relatedUser);
return relationship;
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
}
[HttpDelete("{userId:guid}/friends")]
[Authorize]
public async Task<ActionResult> DeleteFriendRequest(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
try
{
await rels.DeleteFriendRequest(currentUser.Id, userId);
return NoContent();
}
catch (ArgumentException err)
{
return NotFound(err.Message);
}
}
[HttpPost("{userId:guid}/friends/accept")]
[Authorize]
public async Task<ActionResult<Relationship>> AcceptFriendRequest(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var relationship = await rels.GetRelationship(userId, currentUser.Id, RelationshipStatus.Pending);
if (relationship is null) return NotFound("Friend request was not found.");
try
{
relationship = await rels.AcceptFriendRelationship(relationship);
return relationship;
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
}
[HttpPost("{userId:guid}/friends/decline")]
[Authorize]
public async Task<ActionResult<Relationship>> DeclineFriendRequest(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var relationship = await rels.GetRelationship(userId, currentUser.Id, RelationshipStatus.Pending);
if (relationship is null) return NotFound("Friend request was not found.");
try
{
relationship = await rels.AcceptFriendRelationship(relationship, status: RelationshipStatus.Blocked);
return relationship;
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
}
[HttpPost("{userId:guid}/block")]
[Authorize]
public async Task<ActionResult<Relationship>> BlockUser(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var relatedUser = await db.Accounts.FindAsync(userId);
if (relatedUser is null) return NotFound("Account was not found.");
try
{
var relationship = await rels.BlockAccount(currentUser, relatedUser);
return relationship;
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
}
[HttpDelete("{userId:guid}/block")]
[Authorize]
public async Task<ActionResult<Relationship>> UnblockUser(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var relatedUser = await db.Accounts.FindAsync(userId);
if (relatedUser is null) return NotFound("Account was not found.");
try
{
var relationship = await rels.UnblockAccount(currentUser, relatedUser);
return relationship;
}
catch (InvalidOperationException err)
{
return BadRequest(err.Message);
}
}
}

View File

@ -1,207 +0,0 @@
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public class RelationshipService(AppDatabase db, ICacheService cache)
{
private const string UserFriendsCacheKeyPrefix = "accounts:friends:";
private const string UserBlockedCacheKeyPrefix = "accounts:blocked:";
public async Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId)
{
var count = await db.AccountRelationships
.Where(r => (r.AccountId == accountId && r.RelatedId == relatedId) ||
(r.AccountId == relatedId && r.AccountId == accountId))
.CountAsync();
return count > 0;
}
public async Task<Relationship?> GetRelationship(
Guid accountId,
Guid relatedId,
RelationshipStatus? status = null,
bool ignoreExpired = false
)
{
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
var queries = db.AccountRelationships.AsQueryable()
.Where(r => r.AccountId == accountId && r.RelatedId == relatedId);
if (!ignoreExpired) queries = queries.Where(r => r.ExpiredAt == null || r.ExpiredAt > now);
if (status is not null) queries = queries.Where(r => r.Status == status);
var relationship = await queries.FirstOrDefaultAsync();
return relationship;
}
public async Task<Relationship> CreateRelationship(Account sender, Account target, RelationshipStatus status)
{
if (status == RelationshipStatus.Pending)
throw new InvalidOperationException(
"Cannot create relationship with pending status, use SendFriendRequest instead.");
if (await HasExistingRelationship(sender.Id, target.Id))
throw new InvalidOperationException("Found existing relationship between you and target user.");
var relationship = new Relationship
{
AccountId = sender.Id,
RelatedId = target.Id,
Status = status
};
db.AccountRelationships.Add(relationship);
await db.SaveChangesAsync();
await PurgeRelationshipCache(sender.Id, target.Id);
return relationship;
}
public async Task<Relationship> BlockAccount(Account sender, Account target)
{
if (await HasExistingRelationship(sender.Id, target.Id))
return await UpdateRelationship(sender.Id, target.Id, RelationshipStatus.Blocked);
return await CreateRelationship(sender, target, RelationshipStatus.Blocked);
}
public async Task<Relationship> UnblockAccount(Account sender, Account target)
{
var relationship = await GetRelationship(sender.Id, target.Id, RelationshipStatus.Blocked);
if (relationship is null) throw new ArgumentException("There is no relationship between you and the user.");
db.Remove(relationship);
await db.SaveChangesAsync();
await PurgeRelationshipCache(sender.Id, target.Id);
return relationship;
}
public async Task<Relationship> SendFriendRequest(Account sender, Account target)
{
if (await HasExistingRelationship(sender.Id, target.Id))
throw new InvalidOperationException("Found existing relationship between you and target user.");
var relationship = new Relationship
{
AccountId = sender.Id,
RelatedId = target.Id,
Status = RelationshipStatus.Pending,
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(7))
};
db.AccountRelationships.Add(relationship);
await db.SaveChangesAsync();
return relationship;
}
public async Task DeleteFriendRequest(Guid accountId, Guid relatedId)
{
var relationship = await GetRelationship(accountId, relatedId, RelationshipStatus.Pending);
if (relationship is null) throw new ArgumentException("Friend request was not found.");
await db.AccountRelationships
.Where(r => r.AccountId == accountId && r.RelatedId == relatedId && r.Status == RelationshipStatus.Pending)
.ExecuteDeleteAsync();
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
}
public async Task<Relationship> AcceptFriendRelationship(
Relationship relationship,
RelationshipStatus status = RelationshipStatus.Friends
)
{
if (relationship.Status != RelationshipStatus.Pending)
throw new ArgumentException("Cannot accept friend request that not in pending status.");
if (status == RelationshipStatus.Pending)
throw new ArgumentException("Cannot accept friend request by setting the new status to pending.");
// Whatever the receiver decides to apply which status to the relationship,
// the sender should always see the user as a friend since the sender ask for it
relationship.Status = RelationshipStatus.Friends;
relationship.ExpiredAt = null;
db.Update(relationship);
var relationshipBackward = new Relationship
{
AccountId = relationship.RelatedId,
RelatedId = relationship.AccountId,
Status = status
};
db.AccountRelationships.Add(relationshipBackward);
await db.SaveChangesAsync();
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
return relationshipBackward;
}
public async Task<Relationship> UpdateRelationship(Guid accountId, Guid relatedId, RelationshipStatus status)
{
var relationship = await GetRelationship(accountId, relatedId);
if (relationship is null) throw new ArgumentException("There is no relationship between you and the user.");
if (relationship.Status == status) return relationship;
relationship.Status = status;
db.Update(relationship);
await db.SaveChangesAsync();
await PurgeRelationshipCache(accountId, relatedId);
return relationship;
}
public async Task<List<Guid>> ListAccountFriends(Account account)
{
var cacheKey = $"{UserFriendsCacheKeyPrefix}{account.Id}";
var friends = await cache.GetAsync<List<Guid>>(cacheKey);
if (friends == null)
{
friends = await db.AccountRelationships
.Where(r => r.RelatedId == account.Id)
.Where(r => r.Status == RelationshipStatus.Friends)
.Select(r => r.AccountId)
.ToListAsync();
await cache.SetAsync(cacheKey, friends, TimeSpan.FromHours(1));
}
return friends ?? [];
}
public async Task<List<Guid>> ListAccountBlocked(Account account)
{
var cacheKey = $"{UserBlockedCacheKeyPrefix}{account.Id}";
var blocked = await cache.GetAsync<List<Guid>>(cacheKey);
if (blocked == null)
{
blocked = await db.AccountRelationships
.Where(r => r.RelatedId == account.Id)
.Where(r => r.Status == RelationshipStatus.Blocked)
.Select(r => r.AccountId)
.ToListAsync();
await cache.SetAsync(cacheKey, blocked, TimeSpan.FromHours(1));
}
return blocked ?? [];
}
public async Task<bool> HasRelationshipWithStatus(Guid accountId, Guid relatedId,
RelationshipStatus status = RelationshipStatus.Friends)
{
var relationship = await GetRelationship(accountId, relatedId, status);
return relationship is not null;
}
private async Task PurgeRelationshipCache(Guid accountId, Guid relatedId)
{
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}");
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}");
await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{accountId}");
await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{relatedId}");
}
}

View File

@ -1,25 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace DysonNetwork.Sphere.Account;
/// <summary>
/// The verification info of a resource
/// stands, for it is really an individual or organization or a company in the real world.
/// Besides, it can also be use for mark parody or fake.
/// </summary>
public class VerificationMark
{
public VerificationMarkType Type { get; set; }
[MaxLength(1024)] public string? Title { get; set; }
[MaxLength(8192)] public string? Description { get; set; }
[MaxLength(1024)] public string? VerifiedBy { get; set; }
}
public enum VerificationMarkType
{
Official,
Individual,
Organization,
Government,
Creator
}

View File

@ -1,37 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using NodaTime;
namespace DysonNetwork.Sphere.Activity;
public interface IActivity
{
public Activity ToActivity();
}
[NotMapped]
public class Activity : ModelBase
{
public Guid Id { get; set; }
[MaxLength(1024)] public string Type { get; set; } = null!;
[MaxLength(4096)] public string ResourceIdentifier { get; set; } = null!;
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
public object? Data { get; set; }
// Outdated fields, for backward compability
public int Visibility => 0;
public static Activity Empty()
{
var now = SystemClock.Instance.GetCurrentInstant();
return new Activity
{
CreatedAt = now,
UpdatedAt = now,
Id = Guid.NewGuid(),
Type = "empty",
ResourceIdentifier = "none"
};
}
}

View File

@ -1,4 +1,3 @@
using Microsoft.AspNetCore.Mvc;
using NodaTime;
using NodaTime.Text;
@ -22,7 +21,7 @@ public class ActivityController(
/// Besides, when users are logged in, it will also mix the other kinds of data and who're plying to them.
/// </summary>
[HttpGet]
public async Task<ActionResult<List<Activity>>> ListActivities(
public async Task<ActionResult<List<Common.Models.Activity>>> ListActivities(
[FromQuery] string? cursor,
[FromQuery] string? filter,
[FromQuery] int take = 20,
@ -45,7 +44,7 @@ public class ActivityController(
var debugIncludeSet = debugInclude?.Split(',').ToHashSet() ?? new HashSet<string>();
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
return currentUserValue is not Account.Account currentUser
return currentUserValue is Account.Account currentUser
? Ok(await acts.GetActivitiesForAnyone(take, cursorTimestamp, debugIncludeSet))
: Ok(await acts.GetActivities(take, cursorTimestamp, currentUser, filter, debugIncludeSet));
}

View File

@ -1,4 +1,4 @@
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Connection.WebReader;
using DysonNetwork.Sphere.Discovery;
using DysonNetwork.Sphere.Post;
@ -11,11 +11,11 @@ namespace DysonNetwork.Sphere.Activity;
public class ActivityService(
AppDatabase db,
PublisherService pub,
RelationshipService rels,
PostService ps,
DiscoveryService ds)
{
private static double CalculateHotRank(Post.Post post, Instant now)
private static double CalculateHotRank(Common.Models.Post post, Instant now)
{
var score = post.Upvotes - post.Downvotes;
var postTime = post.PublishedAt ?? post.CreatedAt;
@ -24,10 +24,10 @@ public class ActivityService(
return (score + 1) / Math.Pow(hours + 2, 1.8);
}
public async Task<List<Activity>> GetActivitiesForAnyone(int take, Instant? cursor,
public async Task<List<Common.Models.Activity>> GetActivitiesForAnyone(int take, Instant? cursor,
HashSet<string>? debugInclude = null)
{
var activities = new List<Activity>();
var activities = new List<Common.Models.Activity>();
debugInclude ??= new HashSet<string>();
if (cursor == null && (debugInclude.Contains("realms") || Random.Shared.NextDouble() < 0.2))
@ -110,12 +110,12 @@ public class ActivityService(
activities.Add(post.ToActivity());
if (activities.Count == 0)
activities.Add(Activity.Empty());
activities.Add(Common.Models.Activity.Empty());
return activities;
}
public async Task<List<Activity>> GetActivities(
public async Task<List<Common.Models.Activity>> GetActivities(
int take,
Instant? cursor,
Account.Account currentUser,
@ -123,8 +123,7 @@ public class ActivityService(
HashSet<string>? debugInclude = null
)
{
var activities = new List<Activity>();
var userFriends = await rels.ListAccountFriends(currentUser);
var activities = new List<Common.Models.Activity>();
var userPublishers = await pub.GetUserPublishers(currentUser.Id);
debugInclude ??= [];
@ -191,9 +190,7 @@ public class ActivityService(
var filteredPublishers = filter switch
{
"subscriptions" => await pub.GetSubscribedPublishers(currentUser.Id),
"friends" => (await pub.GetUserPublishersBatch(userFriends)).SelectMany(x => x.Value)
.DistinctBy(x => x.Id)
.ToList(),
_ => null
};
@ -214,7 +211,7 @@ public class ActivityService(
// Complete the query with visibility filtering and execute
var posts = await postsQuery
.FilterWithVisibility(currentUser, userFriends, filter is null ? userPublishers : [], isListing: true)
.FilterWithVisibility(filter is null ? userPublishers : [], isListing: true)
.Take(take * 5) // Fetch more posts to have a good pool for ranking
.ToListAsync();
@ -245,12 +242,12 @@ public class ActivityService(
activities.Add(post.ToActivity());
if (activities.Count == 0)
activities.Add(Activity.Empty());
activities.Add(Common.Models.Activity.Empty());
return activities;
}
private static double CalculatePopularity(List<Post.Post> posts)
private static double CalculatePopularity(List<Common.Models.Post> posts)
{
var score = posts.Sum(p => p.Upvotes - p.Downvotes);
var postCount = posts.Count;

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using DysonNetwork.Common.Models;
using NodaTime;
namespace DysonNetwork.Sphere.Activity;
@ -8,10 +9,10 @@ public class DiscoveryActivity(List<DiscoveryItem> items) : IActivity
{
public List<DiscoveryItem> Items { get; set; } = items;
public Activity ToActivity()
public Common.Models.Activity ToActivity()
{
var now = SystemClock.Instance.GetCurrentInstant();
return new Activity
return new Common.Models.Activity
{
Id = Guid.NewGuid(),
Type = "discovery",

View File

@ -1,79 +1,31 @@
using System.Linq.Expressions;
using System.Reflection;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Common.Models;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Publisher;
using DysonNetwork.Sphere.Realm;
using DysonNetwork.Sphere.Sticker;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Wallet;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Query;
using NodaTime;
using Npgsql;
using Quartz;
namespace DysonNetwork.Sphere;
public interface IIdentifiedResource
{
public string ResourceIdentifier { get; }
}
public abstract class ModelBase
{
public Instant CreatedAt { get; set; }
public Instant UpdatedAt { get; set; }
public Instant? DeletedAt { get; set; }
}
public class AppDatabase(
DbContextOptions<AppDatabase> options,
IConfiguration configuration
) : DbContext(options)
{
public DbSet<PermissionNode> PermissionNodes { get; set; }
public DbSet<PermissionGroup> PermissionGroups { get; set; }
public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; }
public DbSet<MagicSpell> MagicSpells { get; set; }
public DbSet<Account.Account> Accounts { get; set; }
public DbSet<AccountConnection> AccountConnections { get; set; }
public DbSet<Profile> AccountProfiles { get; set; }
public DbSet<AccountContact> AccountContacts { get; set; }
public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; }
public DbSet<Relationship> AccountRelationships { get; set; }
public DbSet<Status> AccountStatuses { get; set; }
public DbSet<CheckInResult> AccountCheckInResults { get; set; }
public DbSet<Notification> Notifications { get; set; }
public DbSet<NotificationPushSubscription> NotificationPushSubscriptions { get; set; }
public DbSet<Badge> Badges { get; set; }
public DbSet<ActionLog> ActionLogs { get; set; }
public DbSet<AbuseReport> AbuseReports { get; set; }
public DbSet<Session> AuthSessions { get; set; }
public DbSet<Challenge> AuthChallenges { get; set; }
public DbSet<CloudFile> Files { get; set; }
public DbSet<CloudFileReference> FileReferences { get; set; }
public DbSet<Publisher.Publisher> Publishers { get; set; }
public DbSet<PublisherMember> PublisherMembers { get; set; }
public DbSet<PublisherSubscription> PublisherSubscriptions { get; set; }
public DbSet<Common.Models.Publisher> Publishers { get; set; }
public DbSet<PublisherFeature> PublisherFeatures { get; set; }
public DbSet<Post.Post> Posts { get; set; }
public DbSet<Common.Models.Post> Posts { get; set; }
public DbSet<PostReaction> PostReactions { get; set; }
public DbSet<PostTag> PostTags { get; set; }
public DbSet<PostCategory> PostCategories { get; set; }
public DbSet<PostCollection> PostCollections { get; set; }
public DbSet<Realm.Realm> Realms { get; set; }
public DbSet<Common.Models.Realm> Realms { get; set; }
public DbSet<RealmMember> RealmMembers { get; set; }
public DbSet<Tag> Tags { get; set; }
public DbSet<RealmTag> RealmTags { get; set; }
@ -87,7 +39,7 @@ public class AppDatabase(
public DbSet<Sticker.Sticker> Stickers { get; set; }
public DbSet<StickerPack> StickerPacks { get; set; }
public DbSet<Wallet.Wallet> Wallets { get; set; }
public DbSet<Common.Models.Wallet> Wallets { get; set; }
public DbSet<WalletPocket> WalletPockets { get; set; }
public DbSet<Order> PaymentOrders { get; set; }
public DbSet<Transaction> PaymentTransactions { get; set; }
@ -111,37 +63,7 @@ public class AppDatabase(
.UseNodaTime()
).UseSnakeCaseNamingConvention();
optionsBuilder.UseAsyncSeeding(async (context, _, cancellationToken) =>
{
var defaultPermissionGroup = await context.Set<PermissionGroup>()
.FirstOrDefaultAsync(g => g.Key == "default", cancellationToken);
if (defaultPermissionGroup is null)
{
context.Set<PermissionGroup>().Add(new PermissionGroup
{
Key = "default",
Nodes = new List<string>
{
"posts.create",
"posts.react",
"publishers.create",
"files.create",
"chat.create",
"chat.messages.create",
"chat.realtime.create",
"accounts.statuses.create",
"accounts.statuses.update",
"stickers.packs.create",
"stickers.create"
}.Select(permission =>
PermissionService.NewPermissionNode("group:default", "global", permission, true))
.ToList()
});
await context.SaveChangesAsync(cancellationToken);
}
});
optionsBuilder.UseSeeding((context, _) => {});
base.OnConfiguring(optionsBuilder);
}
@ -149,26 +71,7 @@ public class AppDatabase(
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<PermissionGroupMember>()
.HasKey(pg => new { pg.GroupId, pg.Actor });
modelBuilder.Entity<PermissionGroupMember>()
.HasOne(pg => pg.Group)
.WithMany(g => g.Members)
.HasForeignKey(pg => pg.GroupId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Relationship>()
.HasKey(r => new { FromAccountId = r.AccountId, ToAccountId = r.RelatedId });
modelBuilder.Entity<Relationship>()
.HasOne(r => r.Account)
.WithMany(a => a.OutgoingRelationships)
.HasForeignKey(r => r.AccountId);
modelBuilder.Entity<Relationship>()
.HasOne(r => r.Related)
.WithMany(a => a.IncomingRelationships)
.HasForeignKey(r => r.RelatedId);
modelBuilder.Entity<PublisherMember>()
.HasKey(pm => new { pm.PublisherId, pm.AccountId });
modelBuilder.Entity<PublisherMember>()
@ -176,23 +79,8 @@ public class AppDatabase(
.WithMany(p => p.Members)
.HasForeignKey(pm => pm.PublisherId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<PublisherMember>()
.HasOne(pm => pm.Account)
.WithMany()
.HasForeignKey(pm => pm.AccountId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<PublisherSubscription>()
.HasOne(ps => ps.Publisher)
.WithMany(p => p.Subscriptions)
.HasForeignKey(ps => ps.PublisherId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<PublisherSubscription>()
.HasOne(ps => ps.Account)
.WithMany()
.HasForeignKey(ps => ps.AccountId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Post.Post>()
modelBuilder.Entity<Common.Models.Post>()
.HasGeneratedTsVectorColumn(p => p.SearchVector, "simple", p => new { p.Title, p.Description, p.Content })
.HasIndex(p => p.SearchVector)
.HasMethod("GIN");
@ -207,69 +95,35 @@ public class AppDatabase(
.HasForeignKey(s => s.AppId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Post.Post>()
modelBuilder.Entity<Common.Models.Post>()
.HasOne(p => p.RepliedPost)
.WithMany()
.HasForeignKey(p => p.RepliedPostId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Post.Post>()
modelBuilder.Entity<Common.Models.Post>()
.HasOne(p => p.ForwardedPost)
.WithMany()
.HasForeignKey(p => p.ForwardedPostId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Post.Post>()
modelBuilder.Entity<Common.Models.Post>()
.HasMany(p => p.Tags)
.WithMany(t => t.Posts)
.UsingEntity(j => j.ToTable("post_tag_links"));
modelBuilder.Entity<Post.Post>()
modelBuilder.Entity<Common.Models.Post>()
.HasMany(p => p.Categories)
.WithMany(c => c.Posts)
.UsingEntity(j => j.ToTable("post_category_links"));
modelBuilder.Entity<Post.Post>()
modelBuilder.Entity<Common.Models.Post>()
.HasMany(p => p.Collections)
.WithMany(c => c.Posts)
.UsingEntity(j => j.ToTable("post_collection_links"));
modelBuilder.Entity<RealmMember>()
.HasKey(pm => new { pm.RealmId, pm.AccountId });
modelBuilder.Entity<RealmMember>()
.HasOne(pm => pm.Realm)
.WithMany(p => p.Members)
.HasForeignKey(pm => pm.RealmId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<RealmMember>()
.HasOne(pm => pm.Account)
.WithMany()
.HasForeignKey(pm => pm.AccountId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<RealmTag>()
.HasKey(rt => new { rt.RealmId, rt.TagId });
modelBuilder.Entity<RealmTag>()
.HasOne(rt => rt.Realm)
.WithMany(r => r.RealmTags)
.HasForeignKey(rt => rt.RealmId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<RealmTag>()
.HasOne(rt => rt.Tag)
.WithMany(t => t.RealmTags)
.HasForeignKey(rt => rt.TagId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<ChatMember>()
.HasKey(pm => new { pm.Id });
modelBuilder.Entity<ChatMember>()
.HasAlternateKey(pm => new { pm.ChatRoomId, pm.AccountId });
modelBuilder.Entity<ChatMember>()
.HasOne(pm => pm.ChatRoom)
.WithMany(p => p.Members)
.HasForeignKey(pm => pm.ChatRoomId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<ChatMember>()
.HasOne(pm => pm.Account)
.WithMany()
.HasForeignKey(pm => pm.AccountId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Message>()
.HasOne(m => m.ForwardedMessage)
.WithMany()
@ -280,16 +134,8 @@ public class AppDatabase(
.WithMany()
.HasForeignKey(m => m.RepliedMessageId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<RealtimeCall>()
.HasOne(m => m.Room)
.WithMany()
.HasForeignKey(m => m.RoomId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<RealtimeCall>()
.HasOne(m => m.Sender)
.WithMany()
.HasForeignKey(m => m.SenderId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Connection.WebReader.WebFeed>()
.HasIndex(f => f.Url)
@ -299,128 +145,7 @@ public class AppDatabase(
.HasIndex(a => a.Url)
.IsUnique();
// Automatically apply soft-delete filter to all entities inheriting BaseModel
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (!typeof(ModelBase).IsAssignableFrom(entityType.ClrType)) continue;
var method = typeof(AppDatabase)
.GetMethod(nameof(SetSoftDeleteFilter),
BindingFlags.NonPublic | BindingFlags.Static)!
.MakeGenericMethod(entityType.ClrType);
method.Invoke(null, [modelBuilder]);
}
}
private static void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
where TEntity : ModelBase
{
modelBuilder.Entity<TEntity>().HasQueryFilter(e => e.DeletedAt == null);
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var now = SystemClock.Instance.GetCurrentInstant();
foreach (var entry in ChangeTracker.Entries<ModelBase>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedAt = now;
entry.Entity.UpdatedAt = now;
break;
case EntityState.Modified:
entry.Entity.UpdatedAt = now;
break;
case EntityState.Deleted:
entry.State = EntityState.Modified;
entry.Entity.DeletedAt = now;
break;
case EntityState.Detached:
case EntityState.Unchanged:
default:
break;
}
}
return await base.SaveChangesAsync(cancellationToken);
}
}
public class AppDatabaseRecyclingJob(AppDatabase db, ILogger<AppDatabaseRecyclingJob> logger) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
var now = SystemClock.Instance.GetCurrentInstant();
logger.LogInformation("Cleaning up expired records...");
// Expired relationships
var affectedRows = await db.AccountRelationships
.Where(x => x.ExpiredAt != null && x.ExpiredAt <= now)
.ExecuteDeleteAsync();
logger.LogDebug("Removed {Count} records of expired relationships.", affectedRows);
// Expired permission group members
affectedRows = await db.PermissionGroupMembers
.Where(x => x.ExpiredAt != null && x.ExpiredAt <= now)
.ExecuteDeleteAsync();
logger.LogDebug("Removed {Count} records of expired permission group members.", affectedRows);
logger.LogInformation("Deleting soft-deleted records...");
var threshold = now - Duration.FromDays(7);
var entityTypes = db.Model.GetEntityTypes()
.Where(t => typeof(ModelBase).IsAssignableFrom(t.ClrType) && t.ClrType != typeof(ModelBase))
.Select(t => t.ClrType);
foreach (var entityType in entityTypes)
{
var set = (IQueryable)db.GetType().GetMethod(nameof(DbContext.Set), Type.EmptyTypes)!
.MakeGenericMethod(entityType).Invoke(db, null)!;
var parameter = Expression.Parameter(entityType, "e");
var property = Expression.Property(parameter, nameof(ModelBase.DeletedAt));
var condition = Expression.LessThan(property, Expression.Constant(threshold, typeof(Instant?)));
var notNull = Expression.NotEqual(property, Expression.Constant(null, typeof(Instant?)));
var finalCondition = Expression.AndAlso(notNull, condition);
var lambda = Expression.Lambda(finalCondition, parameter);
var queryable = set.Provider.CreateQuery(
Expression.Call(
typeof(Queryable),
"Where",
[entityType],
set.Expression,
Expression.Quote(lambda)
)
);
var toListAsync = typeof(EntityFrameworkQueryableExtensions)
.GetMethod(nameof(EntityFrameworkQueryableExtensions.ToListAsync))!
.MakeGenericMethod(entityType);
var items = await (dynamic)toListAsync.Invoke(null, [queryable, CancellationToken.None])!;
db.RemoveRange(items);
}
await db.SaveChangesAsync();
}
}
public class AppDatabaseFactory : IDesignTimeDbContextFactory<AppDatabase>
{
public AppDatabase CreateDbContext(string[] args)
{
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
.Build();
var optionsBuilder = new DbContextOptionsBuilder<AppDatabase>();
return new AppDatabase(optionsBuilder.Options, configuration);
}
}
public static class OptionalQueryExtensions
{

View File

@ -1,279 +0,0 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Encodings.Web;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Auth.OidcProvider.Options;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Storage.Handlers;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
using System.Text;
using DysonNetwork.Sphere.Auth.OidcProvider.Controllers;
using DysonNetwork.Sphere.Auth.OidcProvider.Services;
using SystemClock = NodaTime.SystemClock;
namespace DysonNetwork.Sphere.Auth;
public static class AuthConstants
{
public const string SchemeName = "DysonToken";
public const string TokenQueryParamName = "tk";
public const string CookieTokenName = "AuthToken";
}
public enum TokenType
{
AuthKey,
ApiKey,
OidcKey,
Unknown
}
public class TokenInfo
{
public string Token { get; set; } = string.Empty;
public TokenType Type { get; set; } = TokenType.Unknown;
}
public class DysonTokenAuthOptions : AuthenticationSchemeOptions;
public class DysonTokenAuthHandler(
IOptionsMonitor<DysonTokenAuthOptions> options,
IConfiguration configuration,
ILoggerFactory logger,
UrlEncoder encoder,
AppDatabase database,
OidcProviderService oidc,
ICacheService cache,
FlushBufferService fbs
)
: AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder)
{
public const string AuthCachePrefix = "auth:";
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var tokenInfo = _ExtractToken(Request);
if (tokenInfo == null || string.IsNullOrEmpty(tokenInfo.Token))
return AuthenticateResult.Fail("No token was provided.");
try
{
var now = SystemClock.Instance.GetCurrentInstant();
// Validate token and extract session ID
if (!ValidateToken(tokenInfo.Token, out var sessionId))
return AuthenticateResult.Fail("Invalid token.");
// Try to get session from cache first
var session = await cache.GetAsync<Session>($"{AuthCachePrefix}{sessionId}");
// If not in cache, load from database
if (session is null)
{
session = await database.AuthSessions
.Where(e => e.Id == sessionId)
.Include(e => e.Challenge)
.Include(e => e.Account)
.ThenInclude(e => e.Profile)
.FirstOrDefaultAsync();
if (session is not null)
{
// Store in cache for future requests
await cache.SetWithGroupsAsync(
$"auth:{sessionId}",
session,
[$"{AccountService.AccountCachePrefix}{session.Account.Id}"],
TimeSpan.FromHours(1)
);
}
}
// Check if the session exists
if (session == null)
return AuthenticateResult.Fail("Session not found.");
// Check if the session is expired
if (session.ExpiredAt.HasValue && session.ExpiredAt.Value < now)
return AuthenticateResult.Fail("Session expired.");
// Store user and session in the HttpContext.Items for easy access in controllers
Context.Items["CurrentUser"] = session.Account;
Context.Items["CurrentSession"] = session;
Context.Items["CurrentTokenType"] = tokenInfo.Type.ToString();
// Create claims from the session
var claims = new List<Claim>
{
new("user_id", session.Account.Id.ToString()),
new("session_id", session.Id.ToString()),
new("token_type", tokenInfo.Type.ToString())
};
// Add scopes as claims
session.Challenge.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope)));
// Add superuser claim if applicable
if (session.Account.IsSuperuser)
claims.Add(new Claim("is_superuser", "1"));
// Create the identity and principal
var identity = new ClaimsIdentity(claims, AuthConstants.SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, AuthConstants.SchemeName);
var lastInfo = new LastActiveInfo
{
Account = session.Account,
Session = session,
SeenAt = SystemClock.Instance.GetCurrentInstant(),
};
fbs.Enqueue(lastInfo);
return AuthenticateResult.Success(ticket);
}
catch (Exception ex)
{
return AuthenticateResult.Fail($"Authentication failed: {ex.Message}");
}
}
private bool ValidateToken(string token, out Guid sessionId)
{
sessionId = Guid.Empty;
try
{
var parts = token.Split('.');
switch (parts.Length)
{
// Handle JWT tokens (3 parts)
case 3:
{
var (isValid, jwtResult) = oidc.ValidateToken(token);
if (!isValid) return false;
var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value;
if (jti is null) return false;
return Guid.TryParse(jti, out sessionId);
}
// Handle compact tokens (2 parts)
case 2:
// Original compact token validation logic
try
{
// Decode the payload
var payloadBytes = Base64UrlDecode(parts[0]);
// Extract session ID
sessionId = new Guid(payloadBytes);
// Load public key for verification
var publicKeyPem = File.ReadAllText(configuration["AuthToken:PublicKeyPath"]!);
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);
// Verify signature
var signature = Base64UrlDecode(parts[1]);
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
catch
{
return false;
}
break;
default:
return false;
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Token validation failed");
return false;
}
}
private static byte[] Base64UrlDecode(string base64Url)
{
var padded = base64Url
.Replace('-', '+')
.Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
return Convert.FromBase64String(padded);
}
private TokenInfo? _ExtractToken(HttpRequest request)
{
// Check for token in query parameters
if (request.Query.TryGetValue(AuthConstants.TokenQueryParamName, out var queryToken))
{
return new TokenInfo
{
Token = queryToken.ToString(),
Type = TokenType.AuthKey
};
}
// Check for token in Authorization header
var authHeader = request.Headers.Authorization.ToString();
if (!string.IsNullOrEmpty(authHeader))
{
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
var token = authHeader["Bearer ".Length..].Trim();
var parts = token.Split('.');
return new TokenInfo
{
Token = token,
Type = parts.Length == 3 ? TokenType.OidcKey : TokenType.AuthKey
};
}
else if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
{
return new TokenInfo
{
Token = authHeader["AtField ".Length..].Trim(),
Type = TokenType.AuthKey
};
}
else if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
{
return new TokenInfo
{
Token = authHeader["AkField ".Length..].Trim(),
Type = TokenType.ApiKey
};
}
}
// Check for token in cookies
if (request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out var cookieToken))
{
return new TokenInfo
{
Token = cookieToken,
Type = cookieToken.Count(c => c == '.') == 2 ? TokenType.OidcKey : TokenType.AuthKey
};
}
return null;
}
}

View File

@ -1,269 +0,0 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Account;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NodaTime;
using Microsoft.EntityFrameworkCore;
using System.IdentityModel.Tokens.Jwt;
using System.Text.Json;
using DysonNetwork.Sphere.Connection;
namespace DysonNetwork.Sphere.Auth;
[ApiController]
[Route("/auth")]
public class AuthController(
AppDatabase db,
AccountService accounts,
AuthService auth,
GeoIpService geo,
ActionLogService als
) : ControllerBase
{
public class ChallengeRequest
{
[Required] public ChallengePlatform Platform { get; set; }
[Required] [MaxLength(256)] public string Account { get; set; } = null!;
[Required] [MaxLength(512)] public string DeviceId { get; set; } = null!;
public List<string> Audiences { get; set; } = new();
public List<string> Scopes { get; set; } = new();
}
[HttpPost("challenge")]
public async Task<ActionResult<Challenge>> StartChallenge([FromBody] ChallengeRequest request)
{
var account = await accounts.LookupAccount(request.Account);
if (account is null) return NotFound("Account was not found.");
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
// Trying to pick up challenges from the same IP address and user agent
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 existingChallenge;
var challenge = new Challenge
{
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)),
StepTotal = await auth.DetectChallengeRisk(Request, account),
Platform = request.Platform,
Audiences = request.Audiences,
Scopes = request.Scopes,
IpAddress = ipAddress,
UserAgent = userAgent,
Location = geo.GetPointFromIp(ipAddress),
DeviceId = request.DeviceId,
AccountId = account.Id
}.Normalize();
await db.AuthChallenges.AddAsync(challenge);
await db.SaveChangesAsync();
als.CreateActionLogFromRequest(ActionLogType.ChallengeAttempt,
new Dictionary<string, object> { { "challenge_id", challenge.Id } }, Request, account
);
return challenge;
}
[HttpGet("challenge/{id:guid}")]
public async Task<ActionResult<Challenge>> GetChallenge([FromRoute] Guid id)
{
var challenge = await db.AuthChallenges
.Include(e => e.Account)
.ThenInclude(e => e.Profile)
.FirstOrDefaultAsync(e => e.Id == id);
return challenge is null
? NotFound("Auth challenge was not found.")
: challenge;
}
[HttpGet("challenge/{id:guid}/factors")]
public async Task<ActionResult<List<AccountAuthFactor>>> GetChallengeFactors([FromRoute] Guid id)
{
var challenge = await db.AuthChallenges
.Include(e => e.Account)
.Include(e => e.Account.AuthFactors)
.Where(e => e.Id == id)
.FirstOrDefaultAsync();
return challenge is null
? NotFound("Auth challenge was not found.")
: challenge.Account.AuthFactors.Where(e => e is { EnabledAt: not null, Trustworthy: >= 1 }).ToList();
}
[HttpPost("challenge/{id:guid}/factors/{factorId:guid}")]
public async Task<ActionResult> RequestFactorCode(
[FromRoute] Guid id,
[FromRoute] Guid factorId,
[FromBody] string? hint
)
{
var challenge = await db.AuthChallenges
.Include(e => e.Account)
.Where(e => e.Id == id).FirstOrDefaultAsync();
if (challenge is null) return NotFound("Auth challenge was not found.");
var factor = await db.AccountAuthFactors
.Where(e => e.Id == factorId)
.Where(e => e.Account == challenge.Account).FirstOrDefaultAsync();
if (factor is null) return NotFound("Auth factor was not found.");
try
{
await accounts.SendFactorCode(challenge.Account, factor, hint);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
return Ok();
}
public class PerformChallengeRequest
{
[Required] public Guid FactorId { get; set; }
[Required] public string Password { get; set; } = string.Empty;
}
[HttpPatch("challenge/{id:guid}")]
public async Task<ActionResult<Challenge>> DoChallenge(
[FromRoute] Guid id,
[FromBody] PerformChallengeRequest request
)
{
var challenge = await db.AuthChallenges.Include(e => e.Account).FirstOrDefaultAsync(e => e.Id == id);
if (challenge is null) return NotFound("Auth challenge was not found.");
var factor = await db.AccountAuthFactors.FindAsync(request.FactorId);
if (factor is null) return NotFound("Auth factor was not found.");
if (factor.EnabledAt is null) return BadRequest("Auth factor is not enabled.");
if (factor.Trustworthy <= 0) return BadRequest("Auth factor is not trustworthy.");
if (challenge.StepRemain == 0) return challenge;
if (challenge.ExpiredAt.HasValue && challenge.ExpiredAt.Value < Instant.FromDateTimeUtc(DateTime.UtcNow))
return BadRequest();
try
{
if (await accounts.VerifyFactorCode(factor, request.Password))
{
challenge.StepRemain -= factor.Trustworthy;
challenge.StepRemain = Math.Max(0, challenge.StepRemain);
challenge.BlacklistFactors.Add(factor.Id);
db.Update(challenge);
als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess,
new Dictionary<string, object>
{
{ "challenge_id", challenge.Id },
{ "factor_id", factor.Id }
}, Request, challenge.Account
);
}
else
{
throw new ArgumentException("Invalid password.");
}
}
catch
{
challenge.FailedAttempts++;
db.Update(challenge);
als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure,
new Dictionary<string, object>
{
{ "challenge_id", challenge.Id },
{ "factor_id", factor.Id }
}, Request, challenge.Account
);
await db.SaveChangesAsync();
return BadRequest("Invalid password.");
}
if (challenge.StepRemain == 0)
{
als.CreateActionLogFromRequest(ActionLogType.NewLogin,
new Dictionary<string, object>
{
{ "challenge_id", challenge.Id },
{ "account_id", challenge.AccountId }
}, Request, challenge.Account
);
}
await db.SaveChangesAsync();
return challenge;
}
public class TokenExchangeRequest
{
public string GrantType { get; set; } = string.Empty;
public string? RefreshToken { get; set; }
public string? Code { get; set; }
}
public class TokenExchangeResponse
{
public string Token { get; set; } = string.Empty;
}
[HttpPost("token")]
public async Task<ActionResult<TokenExchangeResponse>> ExchangeToken([FromBody] TokenExchangeRequest request)
{
switch (request.GrantType)
{
case "authorization_code":
var code = Guid.TryParse(request.Code, out var codeId) ? codeId : Guid.Empty;
if (code == Guid.Empty)
return BadRequest("Invalid or missing authorization code.");
var challenge = await db.AuthChallenges
.Include(e => e.Account)
.Where(e => e.Id == code)
.FirstOrDefaultAsync();
if (challenge is null)
return BadRequest("Authorization code not found or expired.");
if (challenge.StepRemain != 0)
return BadRequest("Challenge not yet completed.");
var session = await db.AuthSessions
.Where(e => e.Challenge == challenge)
.FirstOrDefaultAsync();
if (session is not null)
return BadRequest("Session already exists for this challenge.");
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 tk = auth.CreateToken(session);
return Ok(new TokenExchangeResponse { Token = tk });
case "refresh_token":
// Since we no longer need the refresh token
// This case is blank for now, thinking to mock it if the OIDC standard requires it
default:
return BadRequest("Unsupported grant type.");
}
}
[HttpPost("captcha")]
public async Task<ActionResult> ValidateCaptcha([FromBody] string token)
{
var result = await auth.ValidateCaptcha(token);
return result ? Ok() : BadRequest();
}
}

View File

@ -1,304 +0,0 @@
using System.Security.Cryptography;
using System.Text.Json;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Auth;
public class AuthService(
AppDatabase db,
IConfiguration config,
IHttpClientFactory httpClientFactory,
IHttpContextAccessor httpContextAccessor,
ICacheService cache
)
{
private HttpContext HttpContext => httpContextAccessor.HttpContext!;
/// <summary>
/// Detect the risk of the current request to login
/// and returns the required steps to login.
/// </summary>
/// <param name="request">The request context</param>
/// <param name="account">The account to login</param>
/// <returns>The required steps to login</returns>
public async Task<int> DetectChallengeRisk(HttpRequest request, Account.Account account)
{
// 1) Find out how many authentication factors the account has enabled.
var maxSteps = await db.AccountAuthFactors
.Where(f => f.AccountId == account.Id)
.Where(f => f.EnabledAt != null)
.CountAsync();
// Well accumulate a “risk score” based on various factors.
// Then we can decide how many total steps are required for the challenge.
var riskScore = 0;
// 2) Get the remote IP address from the request (if any).
var ipAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString();
var lastActiveInfo = await db.AuthSessions
.OrderByDescending(s => s.LastGrantedAt)
.Include(s => s.Challenge)
.Where(s => s.AccountId == account.Id)
.FirstOrDefaultAsync();
// Example check: if IP is missing or in an unusual range, increase the risk.
// (This is just a placeholder; in reality, youd integrate with GeoIpService or a custom check.)
if (string.IsNullOrWhiteSpace(ipAddress))
riskScore += 1;
else
{
if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge.IpAddress) &&
!lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase))
riskScore += 1;
}
// 3) (Optional) Check how recent the last login was.
// If it was a long time ago, the risk might be higher.
var now = SystemClock.Instance.GetCurrentInstant();
var daysSinceLastActive = lastActiveInfo?.LastGrantedAt is not null
? (now - lastActiveInfo.LastGrantedAt.Value).TotalDays
: double.MaxValue;
if (daysSinceLastActive > 30)
riskScore += 1;
// 4) Combine base “maxSteps” (the number of enabled factors) with any accumulated risk score.
const int totalRiskScore = 3;
var totalRequiredSteps = (int)Math.Round((float)maxSteps * riskScore / totalRiskScore);
// Clamp the steps
totalRequiredSteps = Math.Max(Math.Min(totalRequiredSteps, maxSteps), 1);
return totalRequiredSteps;
}
public async Task<Session> CreateSessionForOidcAsync(Account.Account account, Instant time, Guid? customAppId = null)
{
var challenge = new Challenge
{
AccountId = account.Id,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
UserAgent = HttpContext.Request.Headers.UserAgent,
StepRemain = 1,
StepTotal = 1,
Type = customAppId is not null ? ChallengeType.OAuth : ChallengeType.Oidc
};
var session = new Session
{
AccountId = account.Id,
CreatedAt = time,
LastGrantedAt = time,
Challenge = challenge,
AppId = customAppId
};
db.AuthChallenges.Add(challenge);
db.AuthSessions.Add(session);
await db.SaveChangesAsync();
return session;
}
public async Task<bool> ValidateCaptcha(string token)
{
if (string.IsNullOrWhiteSpace(token)) return false;
var provider = config.GetSection("Captcha")["Provider"]?.ToLower();
var apiSecret = config.GetSection("Captcha")["ApiSecret"];
var client = httpClientFactory.CreateClient();
var jsonOpts = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower
};
switch (provider)
{
case "cloudflare":
var content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
"application/x-www-form-urlencoded");
var response = await client.PostAsync("https://challenges.cloudflare.com/turnstile/v0/siteverify",
content);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts);
return result?.Success == true;
case "google":
content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
"application/x-www-form-urlencoded");
response = await client.PostAsync("https://www.google.com/recaptcha/siteverify", content);
response.EnsureSuccessStatusCode();
json = await response.Content.ReadAsStringAsync();
result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts);
return result?.Success == true;
case "hcaptcha":
content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
"application/x-www-form-urlencoded");
response = await client.PostAsync("https://hcaptcha.com/siteverify", content);
response.EnsureSuccessStatusCode();
json = await response.Content.ReadAsStringAsync();
result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts);
return result?.Success == true;
default:
throw new ArgumentException("The server misconfigured for the captcha.");
}
}
public string CreateToken(Session session)
{
// Load the private key for signing
var privateKeyPem = File.ReadAllText(config["AuthToken:PrivateKeyPath"]!);
using var rsa = RSA.Create();
rsa.ImportFromPem(privateKeyPem);
// Create and return a single token
return CreateCompactToken(session.Id, rsa);
}
private string CreateCompactToken(Guid sessionId, RSA rsa)
{
// Create the payload: just the session ID
var payloadBytes = sessionId.ToByteArray();
// Base64Url encode the payload
var payloadBase64 = Base64UrlEncode(payloadBytes);
// Sign the payload with RSA-SHA256
var signature = rsa.SignData(payloadBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
// Base64Url encode the signature
var signatureBase64 = Base64UrlEncode(signature);
// Combine payload and signature with a dot
return $"{payloadBase64}.{signatureBase64}";
}
public async Task<bool> ValidateSudoMode(Session session, string? pinCode)
{
// Check if the session is already in sudo mode (cached)
var sudoModeKey = $"accounts:{session.Id}:sudo";
var (found, _) = await cache.GetAsyncWithStatus<bool>(sudoModeKey);
if (found)
{
// Session is already in sudo mode
return true;
}
// Check if the user has a pin code
var hasPinCode = await db.AccountAuthFactors
.Where(f => f.AccountId == session.AccountId)
.Where(f => f.EnabledAt != null)
.Where(f => f.Type == AccountAuthFactorType.PinCode)
.AnyAsync();
if (!hasPinCode)
{
// User doesn't have a pin code, no validation needed
return true;
}
// If pin code is not provided, we can't validate
if (string.IsNullOrEmpty(pinCode))
{
return false;
}
try
{
// Validate the pin code
var isValid = await ValidatePinCode(session.AccountId, pinCode);
if (isValid)
{
// Set session in sudo mode for 5 minutes
await cache.SetAsync(sudoModeKey, true, TimeSpan.FromMinutes(5));
}
return isValid;
}
catch (InvalidOperationException)
{
// No pin code enabled for this account, so validation is successful
return true;
}
}
public async Task<bool> ValidatePinCode(Guid accountId, string pinCode)
{
var factor = await db.AccountAuthFactors
.Where(f => f.AccountId == accountId)
.Where(f => f.EnabledAt != null)
.Where(f => f.Type == AccountAuthFactorType.PinCode)
.FirstOrDefaultAsync();
if (factor is null) throw new InvalidOperationException("No pin code enabled for this account.");
return factor.VerifyPassword(pinCode);
}
public bool ValidateToken(string token, out Guid sessionId)
{
sessionId = Guid.Empty;
try
{
// Split the token
var parts = token.Split('.');
if (parts.Length != 2)
return false;
// Decode the payload
var payloadBytes = Base64UrlDecode(parts[0]);
// Extract session ID
sessionId = new Guid(payloadBytes);
// Load public key for verification
var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!);
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);
// Verify signature
var signature = Base64UrlDecode(parts[1]);
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
catch
{
return false;
}
}
// Helper methods for Base64Url encoding/decoding
private static string Base64UrlEncode(byte[] data)
{
return Convert.ToBase64String(data)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
private static byte[] Base64UrlDecode(string base64Url)
{
string padded = base64Url
.Replace('-', '+')
.Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
return Convert.FromBase64String(padded);
}
}

View File

@ -1,6 +0,0 @@
namespace DysonNetwork.Sphere.Auth;
public class CaptchaVerificationResponse
{
public bool Success { get; set; }
}

View File

@ -1,94 +0,0 @@
using System.Security.Cryptography;
namespace DysonNetwork.Sphere.Auth;
public class CompactTokenService(IConfiguration config)
{
private readonly string _privateKeyPath = config["AuthToken:PrivateKeyPath"]
?? throw new InvalidOperationException("AuthToken:PrivateKeyPath configuration is missing");
public string CreateToken(Session session)
{
// Load the private key for signing
var privateKeyPem = File.ReadAllText(_privateKeyPath);
using var rsa = RSA.Create();
rsa.ImportFromPem(privateKeyPem);
// Create and return a single token
return CreateCompactToken(session.Id, rsa);
}
private string CreateCompactToken(Guid sessionId, RSA rsa)
{
// Create the payload: just the session ID
var payloadBytes = sessionId.ToByteArray();
// Base64Url encode the payload
var payloadBase64 = Base64UrlEncode(payloadBytes);
// Sign the payload with RSA-SHA256
var signature = rsa.SignData(payloadBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
// Base64Url encode the signature
var signatureBase64 = Base64UrlEncode(signature);
// Combine payload and signature with a dot
return $"{payloadBase64}.{signatureBase64}";
}
public bool ValidateToken(string token, out Guid sessionId)
{
sessionId = Guid.Empty;
try
{
// Split the token
var parts = token.Split('.');
if (parts.Length != 2)
return false;
// Decode the payload
var payloadBytes = Base64UrlDecode(parts[0]);
// Extract session ID
sessionId = new Guid(payloadBytes);
// Load public key for verification
var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!);
using var rsa = RSA.Create();
rsa.ImportFromPem(publicKeyPem);
// Verify signature
var signature = Base64UrlDecode(parts[1]);
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
catch
{
return false;
}
}
// Helper methods for Base64Url encoding/decoding
private static string Base64UrlEncode(byte[] data)
{
return Convert.ToBase64String(data)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
private static byte[] Base64UrlDecode(string base64Url)
{
string padded = base64Url
.Replace('-', '+')
.Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
return Convert.FromBase64String(padded);
}
}

View File

@ -1,242 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using DysonNetwork.Sphere.Auth.OidcProvider.Options;
using DysonNetwork.Sphere.Auth.OidcProvider.Responses;
using DysonNetwork.Sphere.Auth.OidcProvider.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Account;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Controllers;
[Route("/auth/open")]
[ApiController]
public class OidcProviderController(
AppDatabase db,
OidcProviderService oidcService,
IConfiguration configuration,
IOptions<OidcProviderOptions> options,
ILogger<OidcProviderController> logger
)
: ControllerBase
{
[HttpPost("token")]
[Consumes("application/x-www-form-urlencoded")]
public async Task<IActionResult> Token([FromForm] TokenRequest request)
{
switch (request.GrantType)
{
// Validate client credentials
case "authorization_code" when request.ClientId == null || string.IsNullOrEmpty(request.ClientSecret):
return BadRequest("Client credentials are required");
case "authorization_code" when request.Code == null:
return BadRequest("Authorization code is required");
case "authorization_code":
{
var client = await oidcService.FindClientByIdAsync(request.ClientId.Value);
if (client == null ||
!await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret))
return BadRequest(new ErrorResponse
{ Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
// Generate tokens
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
clientId: request.ClientId.Value,
authorizationCode: request.Code!,
redirectUri: request.RedirectUri,
codeVerifier: request.CodeVerifier
);
return Ok(tokenResponse);
}
case "refresh_token" when string.IsNullOrEmpty(request.RefreshToken):
return BadRequest(new ErrorResponse
{ Error = "invalid_request", ErrorDescription = "Refresh token is required" });
case "refresh_token":
{
try
{
// Decode the base64 refresh token to get the session ID
var sessionIdBytes = Convert.FromBase64String(request.RefreshToken);
var sessionId = new Guid(sessionIdBytes);
// Find the session and related data
var session = await oidcService.FindSessionByIdAsync(sessionId);
var now = SystemClock.Instance.GetCurrentInstant();
if (session?.App is null || session.ExpiredAt < now)
{
return BadRequest(new ErrorResponse
{
Error = "invalid_grant",
ErrorDescription = "Invalid or expired refresh token"
});
}
// Get the client
var client = session.App;
if (client == null)
{
return BadRequest(new ErrorResponse
{
Error = "invalid_client",
ErrorDescription = "Client not found"
});
}
// Generate new tokens
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
clientId: session.AppId!.Value,
sessionId: session.Id
);
return Ok(tokenResponse);
}
catch (FormatException)
{
return BadRequest(new ErrorResponse
{
Error = "invalid_grant",
ErrorDescription = "Invalid refresh token format"
});
}
}
default:
return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" });
}
}
[HttpGet("userinfo")]
[Authorize]
public async Task<IActionResult> GetUserInfo()
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser ||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
// Get requested scopes from the token
var scopes = currentSession.Challenge.Scopes;
var userInfo = new Dictionary<string, object>
{
["sub"] = currentUser.Id
};
// Include standard claims based on scopes
if (scopes.Contains("profile") || scopes.Contains("name"))
{
userInfo["name"] = currentUser.Name;
userInfo["preferred_username"] = currentUser.Nick;
}
var userEmail = await db.AccountContacts
.Where(c => c.Type == AccountContactType.Email && c.AccountId == currentUser.Id)
.FirstOrDefaultAsync();
if (scopes.Contains("email") && userEmail is not null)
{
userInfo["email"] = userEmail.Content;
userInfo["email_verified"] = userEmail.VerifiedAt is not null;
}
return Ok(userInfo);
}
[HttpGet("/.well-known/openid-configuration")]
public IActionResult GetConfiguration()
{
var baseUrl = configuration["BaseUrl"];
var issuer = options.Value.IssuerUri.TrimEnd('/');
return Ok(new
{
issuer = issuer,
authorization_endpoint = $"{baseUrl}/auth/authorize",
token_endpoint = $"{baseUrl}/auth/open/token",
userinfo_endpoint = $"{baseUrl}/auth/open/userinfo",
jwks_uri = $"{baseUrl}/.well-known/jwks",
scopes_supported = new[] { "openid", "profile", "email" },
response_types_supported = new[]
{ "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token" },
grant_types_supported = new[] { "authorization_code", "refresh_token" },
token_endpoint_auth_methods_supported = new[] { "client_secret_basic", "client_secret_post" },
id_token_signing_alg_values_supported = new[] { "HS256" },
subject_types_supported = new[] { "public" },
claims_supported = new[] { "sub", "name", "email", "email_verified" },
code_challenge_methods_supported = new[] { "S256" },
response_modes_supported = new[] { "query", "fragment", "form_post" },
request_parameter_supported = true,
request_uri_parameter_supported = true,
require_request_uri_registration = false
});
}
[HttpGet("/.well-known/jwks")]
public IActionResult GetJwks()
{
using var rsa = options.Value.GetRsaPublicKey();
if (rsa == null)
{
return BadRequest("Public key is not configured");
}
var parameters = rsa.ExportParameters(false);
var keyId = Convert.ToBase64String(SHA256.HashData(parameters.Modulus!)[..8])
.Replace("+", "-")
.Replace("/", "_")
.Replace("=", "");
return Ok(new
{
keys = new[]
{
new
{
kty = "RSA",
use = "sig",
kid = keyId,
n = Base64UrlEncoder.Encode(parameters.Modulus!),
e = Base64UrlEncoder.Encode(parameters.Exponent!),
alg = "RS256"
}
}
});
}
}
public class TokenRequest
{
[JsonPropertyName("grant_type")]
[FromForm(Name = "grant_type")]
public string? GrantType { get; set; }
[JsonPropertyName("code")]
[FromForm(Name = "code")]
public string? Code { get; set; }
[JsonPropertyName("redirect_uri")]
[FromForm(Name = "redirect_uri")]
public string? RedirectUri { get; set; }
[JsonPropertyName("client_id")]
[FromForm(Name = "client_id")]
public Guid? ClientId { get; set; }
[JsonPropertyName("client_secret")]
[FromForm(Name = "client_secret")]
public string? ClientSecret { get; set; }
[JsonPropertyName("refresh_token")]
[FromForm(Name = "refresh_token")]
public string? RefreshToken { get; set; }
[JsonPropertyName("scope")]
[FromForm(Name = "scope")]
public string? Scope { get; set; }
[JsonPropertyName("code_verifier")]
[FromForm(Name = "code_verifier")]
public string? CodeVerifier { get; set; }
}

View File

@ -1,17 +0,0 @@
using System;
using System.Collections.Generic;
using NodaTime;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Models;
public class AuthorizationCodeInfo
{
public Guid ClientId { get; set; }
public Guid AccountId { get; set; }
public string RedirectUri { get; set; } = string.Empty;
public List<string> Scopes { get; set; } = new();
public string? CodeChallenge { get; set; }
public string? CodeChallengeMethod { get; set; }
public string? Nonce { get; set; }
public Instant CreatedAt { get; set; }
}

View File

@ -1,36 +0,0 @@
using System.Security.Cryptography;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Options;
public class OidcProviderOptions
{
public string IssuerUri { get; set; } = "https://your-issuer-uri.com";
public string? PublicKeyPath { get; set; }
public string? PrivateKeyPath { get; set; }
public TimeSpan AccessTokenLifetime { get; set; } = TimeSpan.FromHours(1);
public TimeSpan RefreshTokenLifetime { get; set; } = TimeSpan.FromDays(30);
public TimeSpan AuthorizationCodeLifetime { get; set; } = TimeSpan.FromMinutes(5);
public bool RequireHttpsMetadata { get; set; } = true;
public RSA? GetRsaPrivateKey()
{
if (string.IsNullOrEmpty(PrivateKeyPath) || !File.Exists(PrivateKeyPath))
return null;
var privateKey = File.ReadAllText(PrivateKeyPath);
var rsa = RSA.Create();
rsa.ImportFromPem(privateKey.AsSpan());
return rsa;
}
public RSA? GetRsaPublicKey()
{
if (string.IsNullOrEmpty(PublicKeyPath) || !File.Exists(PublicKeyPath))
return null;
var publicKey = File.ReadAllText(PublicKeyPath);
var rsa = RSA.Create();
rsa.ImportFromPem(publicKey.AsSpan());
return rsa;
}
}

View File

@ -1,23 +0,0 @@
using System.Text.Json.Serialization;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses;
public class AuthorizationResponse
{
[JsonPropertyName("code")]
public string Code { get; set; } = null!;
[JsonPropertyName("state")]
public string? State { get; set; }
[JsonPropertyName("scope")]
public string? Scope { get; set; }
[JsonPropertyName("session_state")]
public string? SessionState { get; set; }
[JsonPropertyName("iss")]
public string? Issuer { get; set; }
}

View File

@ -1,20 +0,0 @@
using System.Text.Json.Serialization;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses;
public class ErrorResponse
{
[JsonPropertyName("error")]
public string Error { get; set; } = null!;
[JsonPropertyName("error_description")]
public string? ErrorDescription { get; set; }
[JsonPropertyName("error_uri")]
public string? ErrorUri { get; set; }
[JsonPropertyName("state")]
public string? State { get; set; }
}

View File

@ -1,26 +0,0 @@
using System.Text.Json.Serialization;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Responses;
public class TokenResponse
{
[JsonPropertyName("access_token")]
public string AccessToken { get; set; } = null!;
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; set; }
[JsonPropertyName("token_type")]
public string TokenType { get; set; } = "Bearer";
[JsonPropertyName("refresh_token")]
public string? RefreshToken { get; set; }
[JsonPropertyName("scope")]
public string? Scope { get; set; }
[JsonPropertyName("id_token")]
public string? IdToken { get; set; }
}

View File

@ -1,395 +0,0 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using DysonNetwork.Sphere.Auth.OidcProvider.Models;
using DysonNetwork.Sphere.Auth.OidcProvider.Options;
using DysonNetwork.Sphere.Auth.OidcProvider.Responses;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
namespace DysonNetwork.Sphere.Auth.OidcProvider.Services;
public class OidcProviderService(
AppDatabase db,
AuthService auth,
ICacheService cache,
IOptions<OidcProviderOptions> options,
ILogger<OidcProviderService> logger
)
{
private readonly OidcProviderOptions _options = options.Value;
public async Task<CustomApp?> FindClientByIdAsync(Guid clientId)
{
return await db.CustomApps
.Include(c => c.Secrets)
.FirstOrDefaultAsync(c => c.Id == clientId);
}
public async Task<CustomApp?> FindClientByAppIdAsync(Guid appId)
{
return await db.CustomApps
.Include(c => c.Secrets)
.FirstOrDefaultAsync(c => c.Id == appId);
}
public async Task<Session?> FindValidSessionAsync(Guid accountId, Guid clientId)
{
var now = SystemClock.Instance.GetCurrentInstant();
return await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.AccountId == accountId &&
s.AppId == clientId &&
(s.ExpiredAt == null || s.ExpiredAt > now) &&
s.Challenge.Type == ChallengeType.OAuth)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();
}
public async Task<bool> ValidateClientCredentialsAsync(Guid clientId, string clientSecret)
{
var client = await FindClientByIdAsync(clientId);
if (client == null) return false;
var clock = SystemClock.Instance;
var secret = client.Secrets
.Where(s => s.IsOidc && (s.ExpiredAt == null || s.ExpiredAt > clock.GetCurrentInstant()))
.FirstOrDefault(s => s.Secret == clientSecret); // In production, use proper hashing
return secret != null;
}
public async Task<TokenResponse> GenerateTokenResponseAsync(
Guid clientId,
string? authorizationCode = null,
string? redirectUri = null,
string? codeVerifier = null,
Guid? sessionId = null
)
{
var client = await FindClientByIdAsync(clientId);
if (client == null)
throw new InvalidOperationException("Client not found");
Session session;
var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant();
List<string>? scopes = null;
if (authorizationCode != null)
{
// Authorization code flow
var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
if (authCode is null) throw new InvalidOperationException("Invalid authorization code");
var account = await db.Accounts.Where(a => a.Id == authCode.AccountId).FirstOrDefaultAsync();
if (account is null) throw new InvalidOperationException("Account was not found");
session = await auth.CreateSessionForOidcAsync(account, now, client.Id);
scopes = authCode.Scopes;
}
else if (sessionId.HasValue)
{
// Refresh token flow
session = await FindSessionByIdAsync(sessionId.Value) ??
throw new InvalidOperationException("Invalid session");
// Verify the session is still valid
if (session.ExpiredAt < now)
throw new InvalidOperationException("Session has expired");
}
else
{
throw new InvalidOperationException("Either authorization code or session ID must be provided");
}
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
// Generate an access token
var accessToken = GenerateJwtToken(client, session, expiresAt, scopes);
var refreshToken = GenerateRefreshToken(session);
return new TokenResponse
{
AccessToken = accessToken,
ExpiresIn = expiresIn,
TokenType = "Bearer",
RefreshToken = refreshToken,
Scope = scopes != null ? string.Join(" ", scopes) : null
};
}
private string GenerateJwtToken(
CustomApp client,
Session session,
Instant expiresAt,
IEnumerable<string>? scopes = null
)
{
var tokenHandler = new JwtSecurityTokenHandler();
var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant();
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity([
new Claim(JwtRegisteredClaimNames.Sub, session.AccountId.ToString()),
new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
ClaimValueTypes.Integer64),
new Claim("client_id", client.Id.ToString())
]),
Expires = expiresAt.ToDateTimeUtc(),
Issuer = _options.IssuerUri,
Audience = client.Id.ToString()
};
// Try to use RSA signing if keys are available, fall back to HMAC
var rsaPrivateKey = _options.GetRsaPrivateKey();
tokenDescriptor.SigningCredentials = new SigningCredentials(
new RsaSecurityKey(rsaPrivateKey),
SecurityAlgorithms.RsaSha256
);
// Add scopes as claims if provided
var effectiveScopes = scopes?.ToList() ?? client.OauthConfig!.AllowedScopes?.ToList() ?? [];
if (effectiveScopes.Count != 0)
{
tokenDescriptor.Subject.AddClaims(
effectiveScopes.Select(scope => new Claim("scope", scope)));
}
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
public (bool isValid, JwtSecurityToken? token) ValidateToken(string token)
{
try
{
var tokenHandler = new JwtSecurityTokenHandler();
var validationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = _options.IssuerUri,
ValidateAudience = false,
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero
};
// Try to use RSA validation if public key is available
var rsaPublicKey = _options.GetRsaPublicKey();
validationParameters.IssuerSigningKey = new RsaSecurityKey(rsaPublicKey);
validationParameters.ValidateIssuerSigningKey = true;
validationParameters.ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 };
tokenHandler.ValidateToken(token, validationParameters, out var validatedToken);
return (true, (JwtSecurityToken)validatedToken);
}
catch (Exception ex)
{
logger.LogError(ex, "Token validation failed");
return (false, null);
}
}
public async Task<Session?> FindSessionByIdAsync(Guid sessionId)
{
return await db.AuthSessions
.Include(s => s.Account)
.Include(s => s.Challenge)
.Include(s => s.App)
.FirstOrDefaultAsync(s => s.Id == sessionId);
}
private static string GenerateRefreshToken(Session session)
{
return Convert.ToBase64String(session.Id.ToByteArray());
}
private static bool VerifyHashedSecret(string secret, string hashedSecret)
{
// In a real implementation, you'd use a proper password hashing algorithm like PBKDF2, bcrypt, or Argon2
// For now, we'll do a simple comparison, but you should replace this with proper hashing
return string.Equals(secret, hashedSecret, StringComparison.Ordinal);
}
public async Task<string> GenerateAuthorizationCodeForReuseSessionAsync(
Session session,
Guid clientId,
string redirectUri,
IEnumerable<string> scopes,
string? codeChallenge = null,
string? codeChallengeMethod = null,
string? nonce = null)
{
var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant();
var code = Guid.NewGuid().ToString("N");
// Update the session's last activity time
await db.AuthSessions.Where(s => s.Id == session.Id)
.ExecuteUpdateAsync(s => s.SetProperty(s => s.LastGrantedAt, now));
// Create the authorization code info
var authCodeInfo = new AuthorizationCodeInfo
{
ClientId = clientId,
AccountId = session.AccountId,
RedirectUri = redirectUri,
Scopes = scopes.ToList(),
CodeChallenge = codeChallenge,
CodeChallengeMethod = codeChallengeMethod,
Nonce = nonce,
CreatedAt = now
};
// Store the code with its metadata in the cache
var cacheKey = $"auth:code:{code}";
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, session.AccountId);
return code;
}
public async Task<string> GenerateAuthorizationCodeAsync(
Guid clientId,
Guid userId,
string redirectUri,
IEnumerable<string> scopes,
string? codeChallenge = null,
string? codeChallengeMethod = null,
string? nonce = null
)
{
// Generate a random code
var clock = SystemClock.Instance;
var code = GenerateRandomString(32);
var now = clock.GetCurrentInstant();
// Create the authorization code info
var authCodeInfo = new AuthorizationCodeInfo
{
ClientId = clientId,
AccountId = userId,
RedirectUri = redirectUri,
Scopes = scopes.ToList(),
CodeChallenge = codeChallenge,
CodeChallengeMethod = codeChallengeMethod,
Nonce = nonce,
CreatedAt = now
};
// Store the code with its metadata in the cache
var cacheKey = $"auth:code:{code}";
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId);
return code;
}
private async Task<AuthorizationCodeInfo?> ValidateAuthorizationCodeAsync(
string code,
Guid clientId,
string? redirectUri = null,
string? codeVerifier = null
)
{
var cacheKey = $"auth:code:{code}";
var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey);
if (!found || authCode == null)
{
logger.LogWarning("Authorization code not found: {Code}", code);
return null;
}
// Verify client ID matches
if (authCode.ClientId != clientId)
{
logger.LogWarning(
"Client ID mismatch for code {Code}. Expected: {ExpectedClientId}, Actual: {ActualClientId}",
code, authCode.ClientId, clientId);
return null;
}
// Verify redirect URI if provided
if (!string.IsNullOrEmpty(redirectUri) && authCode.RedirectUri != redirectUri)
{
logger.LogWarning("Redirect URI mismatch for code {Code}", code);
return null;
}
// Verify PKCE code challenge if one was provided during authorization
if (!string.IsNullOrEmpty(authCode.CodeChallenge))
{
if (string.IsNullOrEmpty(codeVerifier))
{
logger.LogWarning("PKCE code verifier is required but not provided for code {Code}", code);
return null;
}
var isValid = authCode.CodeChallengeMethod?.ToUpperInvariant() switch
{
"S256" => VerifyCodeChallenge(codeVerifier, authCode.CodeChallenge, "S256"),
"PLAIN" => VerifyCodeChallenge(codeVerifier, authCode.CodeChallenge, "PLAIN"),
_ => false // Unsupported code challenge method
};
if (!isValid)
{
logger.LogWarning("PKCE code verifier validation failed for code {Code}", code);
return null;
}
}
// Code is valid, remove it from the cache (codes are single-use)
await cache.RemoveAsync(cacheKey);
return authCode;
}
private static string GenerateRandomString(int length)
{
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~";
var random = RandomNumberGenerator.Create();
var result = new char[length];
for (int i = 0; i < length; i++)
{
var randomNumber = new byte[4];
random.GetBytes(randomNumber);
var index = (int)(BitConverter.ToUInt32(randomNumber, 0) % chars.Length);
result[i] = chars[index];
}
return new string(result);
}
private static bool VerifyCodeChallenge(string codeVerifier, string codeChallenge, string method)
{
if (string.IsNullOrEmpty(codeVerifier)) return false;
if (method == "S256")
{
using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
var base64 = Base64UrlEncoder.Encode(hash);
return string.Equals(base64, codeChallenge, StringComparison.Ordinal);
}
if (method == "PLAIN")
{
return string.Equals(codeVerifier, codeChallenge, StringComparison.Ordinal);
}
return false;
}
}

View File

@ -1,94 +0,0 @@
using System.Net.Http.Json;
using System.Text.Json;
using DysonNetwork.Sphere.Storage;
namespace DysonNetwork.Sphere.Auth.OpenId;
public class AfdianOidcService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
AppDatabase db,
AuthService auth,
ICacheService cache,
ILogger<AfdianOidcService> logger
)
: OidcService(configuration, httpClientFactory, db, auth, cache)
{
public override string ProviderName => "Afdian";
protected override string DiscoveryEndpoint => ""; // Afdian doesn't have a standard OIDC discovery endpoint
protected override string ConfigSectionName => "Afdian";
public override string GetAuthorizationUrl(string state, string nonce)
{
var config = GetProviderConfig();
var queryParams = new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "redirect_uri", config.RedirectUri },
{ "response_type", "code" },
{ "scope", "basic" },
{ "state", state },
};
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
return $"https://afdian.com/oauth2/authorize?{queryString}";
}
protected override Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync()
{
return Task.FromResult(new OidcDiscoveryDocument
{
AuthorizationEndpoint = "https://afdian.com/oauth2/authorize",
TokenEndpoint = "https://afdian.com/oauth2/access_token",
UserinfoEndpoint = null,
JwksUri = null
})!;
}
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
{
try
{
var config = GetProviderConfig();
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "client_secret", config.ClientSecret },
{ "grant_type", "authorization_code" },
{ "code", callbackData.Code },
{ "redirect_uri", config.RedirectUri },
});
var client = HttpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/oauth2/access_token");
request.Content = content;
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
logger.LogInformation("Trying get userinfo from afdian, response: {Response}", json);
var afdianResponse = JsonDocument.Parse(json).RootElement;
var user = afdianResponse.TryGetProperty("data", out var dataElement) ? dataElement : default;
var userId = user.TryGetProperty("user_id", out var userIdElement) ? userIdElement.GetString() ?? "" : "";
var avatar = user.TryGetProperty("avatar", out var avatarElement) ? avatarElement.GetString() : null;
return new OidcUserInfo
{
UserId = userId,
DisplayName = (user.TryGetProperty("name", out var nameElement)
? nameElement.GetString()
: null) ?? "",
ProfilePictureUrl = avatar,
Provider = ProviderName
};
}
catch (Exception ex)
{
// Due to afidan's API isn't compliant with OAuth2, we want more logs from it to investigate.
logger.LogError(ex, "Failed to get user info from Afdian");
throw;
}
}
}

View File

@ -1,19 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace DysonNetwork.Sphere.Auth.OpenId;
public class AppleMobileConnectRequest
{
[Required]
public required string IdentityToken { get; set; }
[Required]
public required string AuthorizationCode { get; set; }
}
public class AppleMobileSignInRequest : AppleMobileConnectRequest
{
[Required]
public required string DeviceId { get; set; }
}

View File

@ -1,279 +0,0 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Storage;
using Microsoft.IdentityModel.Tokens;
namespace DysonNetwork.Sphere.Auth.OpenId;
/// <summary>
/// Implementation of OpenID Connect service for Apple Sign In
/// </summary>
public class AppleOidcService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
AppDatabase db,
AuthService auth,
ICacheService cache
)
: OidcService(configuration, httpClientFactory, db, auth, cache)
{
private readonly IConfiguration _configuration = configuration;
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
public override string ProviderName => "apple";
protected override string DiscoveryEndpoint => "https://appleid.apple.com/.well-known/openid-configuration";
protected override string ConfigSectionName => "Apple";
public override string GetAuthorizationUrl(string state, string nonce)
{
var config = GetProviderConfig();
var queryParams = new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "redirect_uri", config.RedirectUri },
{ "response_type", "code id_token" },
{ "scope", "name email" },
{ "response_mode", "form_post" },
{ "state", state },
{ "nonce", nonce }
};
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
return $"https://appleid.apple.com/auth/authorize?{queryString}";
}
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
{
// Verify and decode the id_token
var userInfo = await ValidateTokenAsync(callbackData.IdToken);
// If user data is provided in first login, parse it
if (!string.IsNullOrEmpty(callbackData.RawData))
{
var userData = JsonSerializer.Deserialize<AppleUserData>(callbackData.RawData);
if (userData?.Name != null)
{
userInfo.FirstName = userData.Name.FirstName ?? "";
userInfo.LastName = userData.Name.LastName ?? "";
userInfo.DisplayName = $"{userInfo.FirstName} {userInfo.LastName}".Trim();
}
}
// Exchange authorization code for access token (optional, if you need the access token)
if (string.IsNullOrEmpty(callbackData.Code)) return userInfo;
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code);
if (tokenResponse == null) return userInfo;
userInfo.AccessToken = tokenResponse.AccessToken;
userInfo.RefreshToken = tokenResponse.RefreshToken;
return userInfo;
}
private async Task<OidcUserInfo> ValidateTokenAsync(string idToken)
{
// Get Apple's public keys
var jwksJson = await GetAppleJwksAsync();
var jwks = JsonSerializer.Deserialize<AppleJwks>(jwksJson) ?? new AppleJwks { Keys = new List<AppleKey>() };
// Parse the JWT header to get the key ID
var handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(idToken);
var kid = jwtToken.Header.Kid;
// Find the matching key
var key = jwks.Keys.FirstOrDefault(k => k.Kid == kid);
if (key == null)
{
throw new SecurityTokenValidationException("Unable to find matching key in Apple's JWKS");
}
// Create the validation parameters
var validationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = "https://appleid.apple.com",
ValidateAudience = true,
ValidAudience = GetProviderConfig().ClientId,
ValidateLifetime = true,
IssuerSigningKey = key.ToSecurityKey()
};
return ValidateAndExtractIdToken(idToken, validationParameters);
}
protected override Dictionary<string, string> BuildTokenRequestParameters(
string code,
ProviderConfiguration config,
string? codeVerifier
)
{
var parameters = new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "client_secret", GenerateClientSecret() },
{ "code", code },
{ "grant_type", "authorization_code" },
{ "redirect_uri", config.RedirectUri }
};
return parameters;
}
private async Task<string> GetAppleJwksAsync()
{
var client = _httpClientFactory.CreateClient();
var response = await client.GetAsync("https://appleid.apple.com/auth/keys");
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
/// <summary>
/// Generates a client secret for Apple Sign In using JWT
/// </summary>
private string GenerateClientSecret()
{
var now = DateTime.UtcNow;
var teamId = _configuration["Oidc:Apple:TeamId"];
var clientId = _configuration["Oidc:Apple:ClientId"];
var keyId = _configuration["Oidc:Apple:KeyId"];
var privateKeyPath = _configuration["Oidc:Apple:PrivateKeyPath"];
if (string.IsNullOrEmpty(teamId) || string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(keyId) ||
string.IsNullOrEmpty(privateKeyPath))
{
throw new InvalidOperationException("Apple OIDC configuration is missing required values (TeamId, ClientId, KeyId, PrivateKeyPath).");
}
// Read the private key
var privateKey = File.ReadAllText(privateKeyPath);
// Create the JWT header
var header = new Dictionary<string, object>
{
{ "alg", "ES256" },
{ "kid", keyId }
};
// Create the JWT payload
var payload = new Dictionary<string, object>
{
{ "iss", teamId },
{ "iat", ToUnixTimeSeconds(now) },
{ "exp", ToUnixTimeSeconds(now.AddMinutes(5)) },
{ "aud", "https://appleid.apple.com" },
{ "sub", clientId }
};
// Convert header and payload to Base64Url
var headerJson = JsonSerializer.Serialize(header);
var payloadJson = JsonSerializer.Serialize(payload);
var headerBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson));
var payloadBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson));
// Create the signature
var dataToSign = $"{headerBase64}.{payloadBase64}";
var signature = SignWithECDsa(dataToSign, privateKey);
// Combine all parts
return $"{headerBase64}.{payloadBase64}.{signature}";
}
private long ToUnixTimeSeconds(DateTime dateTime)
{
return new DateTimeOffset(dateTime).ToUnixTimeSeconds();
}
private string SignWithECDsa(string dataToSign, string privateKey)
{
using var ecdsa = ECDsa.Create();
ecdsa.ImportFromPem(privateKey);
var bytes = Encoding.UTF8.GetBytes(dataToSign);
var signature = ecdsa.SignData(bytes, HashAlgorithmName.SHA256);
return Base64UrlEncode(signature);
}
private string Base64UrlEncode(byte[] data)
{
return Convert.ToBase64String(data)
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('=');
}
}
public class AppleUserData
{
[JsonPropertyName("name")] public AppleNameData? Name { get; set; }
[JsonPropertyName("email")] public string? Email { get; set; }
}
public class AppleNameData
{
[JsonPropertyName("firstName")] public string? FirstName { get; set; }
[JsonPropertyName("lastName")] public string? LastName { get; set; }
}
public class AppleJwks
{
[JsonPropertyName("keys")] public List<AppleKey> Keys { get; set; } = new List<AppleKey>();
}
public class AppleKey
{
[JsonPropertyName("kty")] public string? Kty { get; set; }
[JsonPropertyName("kid")] public string? Kid { get; set; }
[JsonPropertyName("use")] public string? Use { get; set; }
[JsonPropertyName("alg")] public string? Alg { get; set; }
[JsonPropertyName("n")] public string? N { get; set; }
[JsonPropertyName("e")] public string? E { get; set; }
public SecurityKey ToSecurityKey()
{
if (Kty != "RSA" || string.IsNullOrEmpty(N) || string.IsNullOrEmpty(E))
{
throw new InvalidOperationException("Invalid key data");
}
var parameters = new RSAParameters
{
Modulus = Base64UrlDecode(N),
Exponent = Base64UrlDecode(E)
};
var rsa = RSA.Create();
rsa.ImportParameters(parameters);
return new RsaSecurityKey(rsa);
}
private byte[] Base64UrlDecode(string input)
{
var output = input
.Replace('-', '+')
.Replace('_', '/');
switch (output.Length % 4)
{
case 0: break;
case 2: output += "=="; break;
case 3: output += "="; break;
default: throw new InvalidOperationException("Invalid base64url string");
}
return Convert.FromBase64String(output);
}
}

View File

@ -1,409 +0,0 @@
using DysonNetwork.Sphere.Account;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DysonNetwork.Sphere.Storage;
using NodaTime;
namespace DysonNetwork.Sphere.Auth.OpenId;
[ApiController]
[Route("/accounts/me/connections")]
[Authorize]
public class ConnectionController(
AppDatabase db,
IEnumerable<OidcService> oidcServices,
AccountService accounts,
AuthService auth,
ICacheService cache
) : ControllerBase
{
private const string StateCachePrefix = "oidc-state:";
private const string ReturnUrlCachePrefix = "oidc-returning:";
private static readonly TimeSpan StateExpiration = TimeSpan.FromMinutes(15);
[HttpGet]
public async Task<ActionResult<List<AccountConnection>>> GetConnections()
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized();
var connections = await db.AccountConnections
.Where(c => c.AccountId == currentUser.Id)
.Select(c => new
{
c.Id,
c.AccountId,
c.Provider,
c.ProvidedIdentifier,
c.Meta,
c.LastUsedAt,
c.CreatedAt,
c.UpdatedAt,
})
.ToListAsync();
return Ok(connections);
}
[HttpDelete("{id:guid}")]
public async Task<ActionResult> RemoveConnection(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized();
var connection = await db.AccountConnections
.Where(c => c.Id == id && c.AccountId == currentUser.Id)
.FirstOrDefaultAsync();
if (connection == null)
return NotFound();
db.AccountConnections.Remove(connection);
await db.SaveChangesAsync();
return Ok();
}
[HttpPost("/auth/connect/apple/mobile")]
public async Task<ActionResult> ConnectAppleMobile([FromBody] AppleMobileConnectRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized();
if (GetOidcService("apple") is not AppleOidcService appleService)
return StatusCode(503, "Apple OIDC service not available");
var callbackData = new OidcCallbackData
{
IdToken = request.IdentityToken,
Code = request.AuthorizationCode,
};
OidcUserInfo userInfo;
try
{
userInfo = await appleService.ProcessCallbackAsync(callbackData);
}
catch (Exception ex)
{
return BadRequest($"Error processing Apple token: {ex.Message}");
}
var existingConnection = await db.AccountConnections
.FirstOrDefaultAsync(c =>
c.Provider == "apple" &&
c.ProvidedIdentifier == userInfo.UserId);
if (existingConnection != null)
{
return BadRequest(
$"This Apple account is already linked to {(existingConnection.AccountId == currentUser.Id ? "your account" : "another user")}.");
}
db.AccountConnections.Add(new AccountConnection
{
AccountId = currentUser.Id,
Provider = "apple",
ProvidedIdentifier = userInfo.UserId!,
AccessToken = userInfo.AccessToken,
RefreshToken = userInfo.RefreshToken,
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
Meta = userInfo.ToMetadata(),
});
await db.SaveChangesAsync();
return Ok(new { message = "Successfully connected Apple account." });
}
private OidcService? GetOidcService(string provider)
{
return oidcServices.FirstOrDefault(s => s.ProviderName.Equals(provider, StringComparison.OrdinalIgnoreCase));
}
public class ConnectProviderRequest
{
public string Provider { get; set; } = null!;
public string? ReturnUrl { get; set; }
}
/// <summary>
/// Initiates manual connection to an OAuth provider for the current user
/// </summary>
[HttpPost("connect")]
public async Task<ActionResult<object>> InitiateConnection([FromBody] ConnectProviderRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized();
var oidcService = GetOidcService(request.Provider);
if (oidcService == null)
return BadRequest($"Provider '{request.Provider}' is not supported");
var existingConnection = await db.AccountConnections
.AnyAsync(c => c.AccountId == currentUser.Id && c.Provider == oidcService.ProviderName);
if (existingConnection)
return BadRequest($"You already have a {request.Provider} connection");
var state = Guid.NewGuid().ToString("N");
var nonce = Guid.NewGuid().ToString("N");
var stateValue = $"{currentUser.Id}|{request.Provider}|{nonce}";
var finalReturnUrl = !string.IsNullOrEmpty(request.ReturnUrl) ? request.ReturnUrl : "/settings/connections";
// Store state and return URL in cache
await cache.SetAsync($"{StateCachePrefix}{state}", stateValue, StateExpiration);
await cache.SetAsync($"{ReturnUrlCachePrefix}{state}", finalReturnUrl, StateExpiration);
var authUrl = oidcService.GetAuthorizationUrl(state, nonce);
return Ok(new
{
authUrl,
message = $"Redirect to this URL to connect your {request.Provider} account"
});
}
[AllowAnonymous]
[Route("/auth/callback/{provider}")]
[HttpGet, HttpPost]
public async Task<IActionResult> HandleCallback([FromRoute] string provider)
{
var oidcService = GetOidcService(provider);
if (oidcService == null)
return BadRequest($"Provider '{provider}' is not supported.");
var callbackData = await ExtractCallbackData(Request);
if (callbackData.State == null)
return BadRequest("State parameter is missing.");
// Get the state from the cache
var stateKey = $"{StateCachePrefix}{callbackData.State}";
// Try to get the state as OidcState first (new format)
var oidcState = await cache.GetAsync<OidcState>(stateKey);
// If not found, try to get as string (legacy format)
if (oidcState == null)
{
var stateValue = await cache.GetAsync<string>(stateKey);
if (string.IsNullOrEmpty(stateValue) || !OidcState.TryParse(stateValue, out oidcState) || oidcState == null)
return BadRequest("Invalid or expired state parameter");
}
// Remove the state from cache to prevent replay attacks
await cache.RemoveAsync(stateKey);
// Handle the flow based on state type
if (oidcState.FlowType == OidcFlowType.Connect && oidcState.AccountId.HasValue)
{
// Connection flow
if (oidcState.DeviceId != null)
{
callbackData.State = oidcState.DeviceId;
}
return await HandleManualConnection(provider, oidcService, callbackData, oidcState.AccountId.Value);
}
else if (oidcState.FlowType == OidcFlowType.Login)
{
// Login/Registration flow
if (!string.IsNullOrEmpty(oidcState.DeviceId))
{
callbackData.State = oidcState.DeviceId;
}
// Store return URL if provided
if (!string.IsNullOrEmpty(oidcState.ReturnUrl) && oidcState.ReturnUrl != "/")
{
var returnUrlKey = $"{ReturnUrlCachePrefix}{callbackData.State}";
await cache.SetAsync(returnUrlKey, oidcState.ReturnUrl, StateExpiration);
}
return await HandleLoginOrRegistration(provider, oidcService, callbackData);
}
return BadRequest("Unsupported flow type");
}
private async Task<IActionResult> HandleManualConnection(
string provider,
OidcService oidcService,
OidcCallbackData callbackData,
Guid accountId
)
{
provider = provider.ToLower();
OidcUserInfo userInfo;
try
{
userInfo = await oidcService.ProcessCallbackAsync(callbackData);
}
catch (Exception ex)
{
return BadRequest($"Error processing {provider} authentication: {ex.Message}");
}
if (string.IsNullOrEmpty(userInfo.UserId))
{
return BadRequest($"{provider} did not return a valid user identifier.");
}
// Extract device ID from the callback state if available
var deviceId = !string.IsNullOrEmpty(callbackData.State) ? callbackData.State : string.Empty;
// Check if this provider account is already connected to any user
var existingConnection = await db.AccountConnections
.FirstOrDefaultAsync(c =>
c.Provider == provider &&
c.ProvidedIdentifier == userInfo.UserId);
// If it's connected to a different user, return error
if (existingConnection != null && existingConnection.AccountId != accountId)
{
return BadRequest($"This {provider} account is already linked to another user.");
}
// Check if the current user already has this provider connected
var userHasProvider = await db.AccountConnections
.AnyAsync(c =>
c.AccountId == accountId &&
c.Provider == provider);
if (userHasProvider)
{
// Update existing connection with new tokens
var connection = await db.AccountConnections
.FirstOrDefaultAsync(c =>
c.AccountId == accountId &&
c.Provider == provider);
if (connection != null)
{
connection.AccessToken = userInfo.AccessToken;
connection.RefreshToken = userInfo.RefreshToken;
connection.LastUsedAt = SystemClock.Instance.GetCurrentInstant();
connection.Meta = userInfo.ToMetadata();
}
}
else
{
// Create new connection
db.AccountConnections.Add(new AccountConnection
{
AccountId = accountId,
Provider = provider,
ProvidedIdentifier = userInfo.UserId!,
AccessToken = userInfo.AccessToken,
RefreshToken = userInfo.RefreshToken,
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
Meta = userInfo.ToMetadata(),
});
}
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException)
{
return StatusCode(500, $"Failed to save {provider} connection. Please try again.");
}
// Clean up and redirect
var returnUrlKey = $"{ReturnUrlCachePrefix}{callbackData.State}";
var returnUrl = await cache.GetAsync<string>(returnUrlKey);
await cache.RemoveAsync(returnUrlKey);
return Redirect(string.IsNullOrEmpty(returnUrl) ? "/auth/callback" : returnUrl);
}
private async Task<IActionResult> HandleLoginOrRegistration(
string provider,
OidcService oidcService,
OidcCallbackData callbackData
)
{
OidcUserInfo userInfo;
try
{
userInfo = await oidcService.ProcessCallbackAsync(callbackData);
}
catch (Exception ex)
{
return BadRequest($"Error processing callback: {ex.Message}");
}
if (string.IsNullOrEmpty(userInfo.Email) || string.IsNullOrEmpty(userInfo.UserId))
{
return BadRequest($"Email or user ID is missing from {provider}'s response");
}
var connection = await db.AccountConnections
.Include(c => c.Account)
.FirstOrDefaultAsync(c => c.Provider == provider && c.ProvidedIdentifier == userInfo.UserId);
var clock = SystemClock.Instance;
if (connection != null)
{
// Login existing user
var deviceId = !string.IsNullOrEmpty(callbackData.State) ?
callbackData.State.Split('|').FirstOrDefault() :
string.Empty;
var challenge = await oidcService.CreateChallengeForUserAsync(
userInfo,
connection.Account,
HttpContext,
deviceId ?? string.Empty);
return Redirect($"/auth/callback?challenge={challenge.Id}");
}
// Register new user
var account = await accounts.LookupAccount(userInfo.Email) ?? await accounts.CreateAccount(userInfo);
// Create connection for new or existing user
var newConnection = new AccountConnection
{
Account = account,
Provider = provider,
ProvidedIdentifier = userInfo.UserId!,
AccessToken = userInfo.AccessToken,
RefreshToken = userInfo.RefreshToken,
LastUsedAt = clock.GetCurrentInstant(),
Meta = userInfo.ToMetadata()
};
db.AccountConnections.Add(newConnection);
await db.SaveChangesAsync();
var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant());
var loginToken = auth.CreateToken(loginSession);
return Redirect($"/auth/token?token={loginToken}");
}
private static async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request)
{
var data = new OidcCallbackData();
switch (request.Method)
{
case "GET":
data.Code = Uri.UnescapeDataString(request.Query["code"].FirstOrDefault() ?? "");
data.IdToken = Uri.UnescapeDataString(request.Query["id_token"].FirstOrDefault() ?? "");
data.State = Uri.UnescapeDataString(request.Query["state"].FirstOrDefault() ?? "");
break;
case "POST" when request.HasFormContentType:
{
var form = await request.ReadFormAsync();
data.Code = Uri.UnescapeDataString(form["code"].FirstOrDefault() ?? "");
data.IdToken = Uri.UnescapeDataString(form["id_token"].FirstOrDefault() ?? "");
data.State = Uri.UnescapeDataString(form["state"].FirstOrDefault() ?? "");
if (form.ContainsKey("user"))
data.RawData = Uri.UnescapeDataString(form["user"].FirstOrDefault() ?? "");
break;
}
}
return data;
}
}

View File

@ -1,115 +0,0 @@
using System.Net.Http.Json;
using System.Text.Json;
using DysonNetwork.Sphere.Storage;
namespace DysonNetwork.Sphere.Auth.OpenId;
public class DiscordOidcService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
AppDatabase db,
AuthService auth,
ICacheService cache
)
: OidcService(configuration, httpClientFactory, db, auth, cache)
{
public override string ProviderName => "Discord";
protected override string DiscoveryEndpoint => ""; // Discord doesn't have a standard OIDC discovery endpoint
protected override string ConfigSectionName => "Discord";
public override string GetAuthorizationUrl(string state, string nonce)
{
var config = GetProviderConfig();
var queryParams = new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "redirect_uri", config.RedirectUri },
{ "response_type", "code" },
{ "scope", "identify email" },
{ "state", state },
};
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
return $"https://discord.com/oauth2/authorize?{queryString}";
}
protected override Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync()
{
return Task.FromResult(new OidcDiscoveryDocument
{
AuthorizationEndpoint = "https://discord.com/oauth2/authorize",
TokenEndpoint = "https://discord.com/oauth2/token",
UserinfoEndpoint = "https://discord.com/users/@me",
JwksUri = null
})!;
}
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
{
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code);
if (tokenResponse?.AccessToken == null)
{
throw new InvalidOperationException("Failed to obtain access token from Discord");
}
var userInfo = await GetUserInfoAsync(tokenResponse.AccessToken);
userInfo.AccessToken = tokenResponse.AccessToken;
userInfo.RefreshToken = tokenResponse.RefreshToken;
return userInfo;
}
protected override async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code,
string? codeVerifier = null)
{
var config = GetProviderConfig();
var client = HttpClientFactory.CreateClient();
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "client_secret", config.ClientSecret },
{ "grant_type", "authorization_code" },
{ "code", code },
{ "redirect_uri", config.RedirectUri },
});
var response = await client.PostAsync("https://discord.com/oauth2/token", content);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
}
private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken)
{
var client = HttpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, "https://discord.com/users/@me");
request.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var discordUser = JsonDocument.Parse(json).RootElement;
var userId = discordUser.GetProperty("id").GetString() ?? "";
var avatar = discordUser.TryGetProperty("avatar", out var avatarElement) ? avatarElement.GetString() : null;
return new OidcUserInfo
{
UserId = userId,
Email = (discordUser.TryGetProperty("email", out var emailElement) ? emailElement.GetString() : null) ?? "",
EmailVerified = discordUser.TryGetProperty("verified", out var verifiedElement) &&
verifiedElement.GetBoolean(),
DisplayName = (discordUser.TryGetProperty("global_name", out var globalNameElement)
? globalNameElement.GetString()
: null) ?? "",
PreferredUsername = discordUser.GetProperty("username").GetString() ?? "",
ProfilePictureUrl = !string.IsNullOrEmpty(avatar)
? $"https://cdn.discordapp.com/avatars/{userId}/{avatar}.png"
: "",
Provider = ProviderName
};
}
}

View File

@ -1,127 +0,0 @@
using System.Net.Http.Json;
using System.Text.Json;
using DysonNetwork.Sphere.Storage;
namespace DysonNetwork.Sphere.Auth.OpenId;
public class GitHubOidcService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
AppDatabase db,
AuthService auth,
ICacheService cache
)
: OidcService(configuration, httpClientFactory, db, auth, cache)
{
public override string ProviderName => "GitHub";
protected override string DiscoveryEndpoint => ""; // GitHub doesn't have a standard OIDC discovery endpoint
protected override string ConfigSectionName => "GitHub";
public override string GetAuthorizationUrl(string state, string nonce)
{
var config = GetProviderConfig();
var queryParams = new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "redirect_uri", config.RedirectUri },
{ "scope", "user:email" },
{ "state", state },
};
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
return $"https://github.com/login/oauth/authorize?{queryString}";
}
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
{
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code);
if (tokenResponse?.AccessToken == null)
{
throw new InvalidOperationException("Failed to obtain access token from GitHub");
}
var userInfo = await GetUserInfoAsync(tokenResponse.AccessToken);
userInfo.AccessToken = tokenResponse.AccessToken;
userInfo.RefreshToken = tokenResponse.RefreshToken;
return userInfo;
}
protected override async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code,
string? codeVerifier = null)
{
var config = GetProviderConfig();
var client = HttpClientFactory.CreateClient();
var tokenRequest = new HttpRequestMessage(HttpMethod.Post, "https://github.com/login/oauth/access_token")
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "client_secret", config.ClientSecret },
{ "code", code },
{ "redirect_uri", config.RedirectUri },
})
};
tokenRequest.Headers.Add("Accept", "application/json");
var response = await client.SendAsync(tokenRequest);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
}
private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken)
{
var client = HttpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user");
request.Headers.Add("Authorization", $"Bearer {accessToken}");
request.Headers.Add("User-Agent", "DysonNetwork.Sphere");
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var githubUser = JsonDocument.Parse(json).RootElement;
var email = githubUser.TryGetProperty("email", out var emailElement) ? emailElement.GetString() : null;
if (string.IsNullOrEmpty(email))
{
email = await GetPrimaryEmailAsync(accessToken);
}
return new OidcUserInfo
{
UserId = githubUser.GetProperty("id").GetInt64().ToString(),
Email = email,
DisplayName = githubUser.TryGetProperty("name", out var nameElement) ? nameElement.GetString() ?? "" : "",
PreferredUsername = githubUser.GetProperty("login").GetString() ?? "",
ProfilePictureUrl = githubUser.TryGetProperty("avatar_url", out var avatarElement)
? avatarElement.GetString() ?? ""
: "",
Provider = ProviderName
};
}
private async Task<string?> GetPrimaryEmailAsync(string accessToken)
{
var client = HttpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user/emails");
request.Headers.Add("Authorization", $"Bearer {accessToken}");
request.Headers.Add("User-Agent", "DysonNetwork.Sphere");
var response = await client.SendAsync(request);
if (!response.IsSuccessStatusCode) return null;
var emails = await response.Content.ReadFromJsonAsync<List<GitHubEmail>>();
return emails?.FirstOrDefault(e => e.Primary)?.Email;
}
private class GitHubEmail
{
public string Email { get; set; } = "";
public bool Primary { get; set; }
public bool Verified { get; set; }
}
}

View File

@ -1,136 +0,0 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using DysonNetwork.Sphere.Storage;
using Microsoft.IdentityModel.Tokens;
namespace DysonNetwork.Sphere.Auth.OpenId;
public class GoogleOidcService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
AppDatabase db,
AuthService auth,
ICacheService cache
)
: OidcService(configuration, httpClientFactory, db, auth, cache)
{
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
public override string ProviderName => "google";
protected override string DiscoveryEndpoint => "https://accounts.google.com/.well-known/openid-configuration";
protected override string ConfigSectionName => "Google";
public override string GetAuthorizationUrl(string state, string nonce)
{
var config = GetProviderConfig();
var discoveryDocument = GetDiscoveryDocumentAsync().GetAwaiter().GetResult();
if (discoveryDocument?.AuthorizationEndpoint == null)
{
throw new InvalidOperationException("Authorization endpoint not found in discovery document");
}
var queryParams = new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "redirect_uri", config.RedirectUri },
{ "response_type", "code" },
{ "scope", "openid email profile" },
{ "state", state }, // No '|codeVerifier' appended anymore
{ "nonce", nonce }
};
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
return $"{discoveryDocument.AuthorizationEndpoint}?{queryString}";
}
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
{
// No need to split or parse code verifier from state
var state = callbackData.State ?? "";
callbackData.State = state; // Keep the original state if needed
// Exchange the code for tokens
// Pass null or omit the parameter for codeVerifier as PKCE is removed
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code, null);
if (tokenResponse?.IdToken == null)
{
throw new InvalidOperationException("Failed to obtain ID token from Google");
}
// Validate the ID token
var userInfo = await ValidateTokenAsync(tokenResponse.IdToken);
// Set tokens on the user info
userInfo.AccessToken = tokenResponse.AccessToken;
userInfo.RefreshToken = tokenResponse.RefreshToken;
// Try to fetch additional profile data if userinfo endpoint is available
try
{
var discoveryDocument = await GetDiscoveryDocumentAsync();
if (discoveryDocument?.UserinfoEndpoint != null && !string.IsNullOrEmpty(tokenResponse.AccessToken))
{
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken);
var userInfoResponse =
await client.GetFromJsonAsync<Dictionary<string, object>>(discoveryDocument.UserinfoEndpoint);
if (userInfoResponse != null)
{
if (userInfoResponse.TryGetValue("picture", out var picture) && picture != null)
{
userInfo.ProfilePictureUrl = picture.ToString();
}
}
}
}
catch
{
// Ignore errors when fetching additional profile data
}
return userInfo;
}
private async Task<OidcUserInfo> ValidateTokenAsync(string idToken)
{
var discoveryDocument = await GetDiscoveryDocumentAsync();
if (discoveryDocument?.JwksUri == null)
{
throw new InvalidOperationException("JWKS URI not found in discovery document");
}
var client = _httpClientFactory.CreateClient();
var jwksResponse = await client.GetFromJsonAsync<JsonWebKeySet>(discoveryDocument.JwksUri);
if (jwksResponse == null)
{
throw new InvalidOperationException("Failed to retrieve JWKS from Google");
}
var handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(idToken);
var kid = jwtToken.Header.Kid;
var signingKey = jwksResponse.Keys.FirstOrDefault(k => k.Kid == kid);
if (signingKey == null)
{
throw new SecurityTokenValidationException("Unable to find matching key in Google's JWKS");
}
var validationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = "https://accounts.google.com",
ValidateAudience = true,
ValidAudience = GetProviderConfig().ClientId,
ValidateLifetime = true,
IssuerSigningKey = signingKey
};
return ValidateAndExtractIdToken(idToken, validationParameters);
}
}

View File

@ -1,124 +0,0 @@
using System.Net.Http.Json;
using System.Text.Json;
using DysonNetwork.Sphere.Storage;
namespace DysonNetwork.Sphere.Auth.OpenId;
public class MicrosoftOidcService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
AppDatabase db,
AuthService auth,
ICacheService cache
)
: OidcService(configuration, httpClientFactory, db, auth, cache)
{
public override string ProviderName => "Microsoft";
protected override string DiscoveryEndpoint => Configuration[$"Oidc:{ConfigSectionName}:DiscoveryEndpoint"] ??
throw new InvalidOperationException(
"Microsoft OIDC discovery endpoint is not configured.");
protected override string ConfigSectionName => "Microsoft";
public override string GetAuthorizationUrl(string state, string nonce)
{
var config = GetProviderConfig();
var discoveryDocument = GetDiscoveryDocumentAsync().GetAwaiter().GetResult();
if (discoveryDocument?.AuthorizationEndpoint == null)
throw new InvalidOperationException("Authorization endpoint not found in discovery document.");
var queryParams = new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "response_type", "code" },
{ "redirect_uri", config.RedirectUri },
{ "response_mode", "query" },
{ "scope", "openid profile email" },
{ "state", state },
{ "nonce", nonce },
};
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
return $"{discoveryDocument.AuthorizationEndpoint}?{queryString}";
}
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
{
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code);
if (tokenResponse?.AccessToken == null)
{
throw new InvalidOperationException("Failed to obtain access token from Microsoft");
}
var userInfo = await GetUserInfoAsync(tokenResponse.AccessToken);
userInfo.AccessToken = tokenResponse.AccessToken;
userInfo.RefreshToken = tokenResponse.RefreshToken;
return userInfo;
}
protected override async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code,
string? codeVerifier = null)
{
var config = GetProviderConfig();
var discoveryDocument = await GetDiscoveryDocumentAsync();
if (discoveryDocument?.TokenEndpoint == null)
{
throw new InvalidOperationException("Token endpoint not found in discovery document.");
}
var client = HttpClientFactory.CreateClient();
var tokenRequest = new HttpRequestMessage(HttpMethod.Post, discoveryDocument.TokenEndpoint)
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "scope", "openid profile email" },
{ "code", code },
{ "redirect_uri", config.RedirectUri },
{ "grant_type", "authorization_code" },
{ "client_secret", config.ClientSecret },
})
};
var response = await client.SendAsync(tokenRequest);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
}
private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken)
{
var discoveryDocument = await GetDiscoveryDocumentAsync();
if (discoveryDocument?.UserinfoEndpoint == null)
throw new InvalidOperationException("Userinfo endpoint not found in discovery document.");
var client = HttpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, discoveryDocument.UserinfoEndpoint);
request.Headers.Add("Authorization", $"Bearer {accessToken}");
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var microsoftUser = JsonDocument.Parse(json).RootElement;
return new OidcUserInfo
{
UserId = microsoftUser.GetProperty("sub").GetString() ?? "",
Email = microsoftUser.TryGetProperty("email", out var emailElement) ? emailElement.GetString() : null,
DisplayName =
microsoftUser.TryGetProperty("name", out var nameElement) ? nameElement.GetString() ?? "" : "",
PreferredUsername = microsoftUser.TryGetProperty("preferred_username", out var preferredUsernameElement)
? preferredUsernameElement.GetString() ?? ""
: "",
ProfilePictureUrl = microsoftUser.TryGetProperty("picture", out var pictureElement)
? pictureElement.GetString() ?? ""
: "",
Provider = ProviderName
};
}
}

View File

@ -1,194 +0,0 @@
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
namespace DysonNetwork.Sphere.Auth.OpenId;
[ApiController]
[Route("/auth/login")]
public class OidcController(
IServiceProvider serviceProvider,
AppDatabase db,
AccountService accounts,
ICacheService cache
)
: ControllerBase
{
private const string StateCachePrefix = "oidc-state:";
private static readonly TimeSpan StateExpiration = TimeSpan.FromMinutes(15);
[HttpGet("{provider}")]
public async Task<ActionResult> OidcLogin(
[FromRoute] string provider,
[FromQuery] string? returnUrl = "/",
[FromHeader(Name = "X-Device-Id")] string? deviceId = null
)
{
try
{
var oidcService = GetOidcService(provider);
// If the user is already authenticated, treat as an account connection request
if (HttpContext.Items["CurrentUser"] is Account.Account currentUser)
{
var state = Guid.NewGuid().ToString();
var nonce = Guid.NewGuid().ToString();
// Create and store connection state
var oidcState = OidcState.ForConnection(currentUser.Id, provider, nonce, deviceId);
await cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration);
// The state parameter sent to the provider is the GUID key for the cache.
var authUrl = oidcService.GetAuthorizationUrl(state, nonce);
return Redirect(authUrl);
}
else // Otherwise, proceed with the login / registration flow
{
var nonce = Guid.NewGuid().ToString();
var state = Guid.NewGuid().ToString();
// Create login state with return URL and device ID
var oidcState = OidcState.ForLogin(returnUrl ?? "/", deviceId);
await cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration);
var authUrl = oidcService.GetAuthorizationUrl(state, nonce);
return Redirect(authUrl);
}
}
catch (Exception ex)
{
return BadRequest($"Error initiating OpenID Connect flow: {ex.Message}");
}
}
/// <summary>
/// Mobile Apple Sign In endpoint
/// Handles Apple authentication directly from mobile apps
/// </summary>
[HttpPost("apple/mobile")]
public async Task<ActionResult<Challenge>> AppleMobileLogin(
[FromBody] AppleMobileSignInRequest request)
{
try
{
// Get Apple OIDC service
if (GetOidcService("apple") is not AppleOidcService appleService)
return StatusCode(503, "Apple OIDC service not available");
// Prepare callback data for processing
var callbackData = new OidcCallbackData
{
IdToken = request.IdentityToken,
Code = request.AuthorizationCode,
};
// Process the authentication
var userInfo = await appleService.ProcessCallbackAsync(callbackData);
// Find or create user account using existing logic
var account = await FindOrCreateAccount(userInfo, "apple");
// Create session using the OIDC service
var challenge = await appleService.CreateChallengeForUserAsync(
userInfo,
account,
HttpContext,
request.DeviceId
);
return Ok(challenge);
}
catch (SecurityTokenValidationException ex)
{
return Unauthorized($"Invalid identity token: {ex.Message}");
}
catch (Exception ex)
{
// Log the error
return StatusCode(500, $"Authentication failed: {ex.Message}");
}
}
private OidcService GetOidcService(string provider)
{
return provider.ToLower() switch
{
"apple" => serviceProvider.GetRequiredService<AppleOidcService>(),
"google" => serviceProvider.GetRequiredService<GoogleOidcService>(),
"microsoft" => serviceProvider.GetRequiredService<MicrosoftOidcService>(),
"discord" => serviceProvider.GetRequiredService<DiscordOidcService>(),
"github" => serviceProvider.GetRequiredService<GitHubOidcService>(),
"afdian" => serviceProvider.GetRequiredService<AfdianOidcService>(),
_ => throw new ArgumentException($"Unsupported provider: {provider}")
};
}
private async Task<Account.Account> FindOrCreateAccount(OidcUserInfo userInfo, string provider)
{
if (string.IsNullOrEmpty(userInfo.Email))
throw new ArgumentException("Email is required for account creation");
// Check if an account exists by email
var existingAccount = await accounts.LookupAccount(userInfo.Email);
if (existingAccount != null)
{
// Check if this provider connection already exists
var existingConnection = await db.AccountConnections
.FirstOrDefaultAsync(c => c.AccountId == existingAccount.Id &&
c.Provider == provider &&
c.ProvidedIdentifier == userInfo.UserId);
// If no connection exists, create one
if (existingConnection != null)
{
await db.AccountConnections
.Where(c => c.AccountId == existingAccount.Id &&
c.Provider == provider &&
c.ProvidedIdentifier == userInfo.UserId)
.ExecuteUpdateAsync(s => s
.SetProperty(c => c.LastUsedAt, SystemClock.Instance.GetCurrentInstant())
.SetProperty(c => c.Meta, userInfo.ToMetadata()));
return existingAccount;
}
var connection = new AccountConnection
{
AccountId = existingAccount.Id,
Provider = provider,
ProvidedIdentifier = userInfo.UserId!,
AccessToken = userInfo.AccessToken,
RefreshToken = userInfo.RefreshToken,
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
Meta = userInfo.ToMetadata()
};
await db.AccountConnections.AddAsync(connection);
await db.SaveChangesAsync();
return existingAccount;
}
// Create new account using the AccountService
var newAccount = await accounts.CreateAccount(userInfo);
// Create the provider connection
var newConnection = new AccountConnection
{
AccountId = newAccount.Id,
Provider = provider,
ProvidedIdentifier = userInfo.UserId!,
AccessToken = userInfo.AccessToken,
RefreshToken = userInfo.RefreshToken,
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
Meta = userInfo.ToMetadata()
};
db.AccountConnections.Add(newConnection);
await db.SaveChangesAsync();
return newAccount;
}
}

View File

@ -1,295 +0,0 @@
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
namespace DysonNetwork.Sphere.Auth.OpenId;
/// <summary>
/// Base service for OpenID Connect authentication providers
/// </summary>
public abstract class OidcService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
AppDatabase db,
AuthService auth,
ICacheService cache
)
{
protected readonly IConfiguration Configuration = configuration;
protected readonly IHttpClientFactory HttpClientFactory = httpClientFactory;
protected readonly AppDatabase Db = db;
/// <summary>
/// Gets the unique identifier for this provider
/// </summary>
public abstract string ProviderName { get; }
/// <summary>
/// Gets the OIDC discovery document endpoint
/// </summary>
protected abstract string DiscoveryEndpoint { get; }
/// <summary>
/// Gets configuration section name for this provider
/// </summary>
protected abstract string ConfigSectionName { get; }
/// <summary>
/// Gets the authorization URL for initiating the authentication flow
/// </summary>
public abstract string GetAuthorizationUrl(string state, string nonce);
/// <summary>
/// Process the callback from the OIDC provider
/// </summary>
public abstract Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData);
/// <summary>
/// Gets the provider configuration
/// </summary>
protected ProviderConfiguration GetProviderConfig()
{
return new ProviderConfiguration
{
ClientId = Configuration[$"Oidc:{ConfigSectionName}:ClientId"] ?? "",
ClientSecret = Configuration[$"Oidc:{ConfigSectionName}:ClientSecret"] ?? "",
RedirectUri = Configuration["BaseUrl"] + "/auth/callback/" + ProviderName.ToLower()
};
}
/// <summary>
/// Retrieves the OpenID Connect discovery document
/// </summary>
protected virtual async Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync()
{
// Construct a cache key unique to the current provider:
var cacheKey = $"oidc-discovery:{ProviderName}";
// Try getting the discovery document from cache first:
var (found, cachedDoc) = await cache.GetAsyncWithStatus<OidcDiscoveryDocument>(cacheKey);
if (found && cachedDoc != null)
{
return cachedDoc;
}
// If it's not cached, fetch from the actual discovery endpoint:
var client = HttpClientFactory.CreateClient();
var response = await client.GetAsync(DiscoveryEndpoint);
response.EnsureSuccessStatusCode();
var doc = await response.Content.ReadFromJsonAsync<OidcDiscoveryDocument>();
// Store the discovery document in the cache for a while (e.g., 15 minutes):
if (doc is not null)
await cache.SetAsync(cacheKey, doc, TimeSpan.FromMinutes(15));
return doc;
}
/// <summary>
/// Exchange the authorization code for tokens
/// </summary>
protected virtual async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code,
string? codeVerifier = null)
{
var config = GetProviderConfig();
var discoveryDocument = await GetDiscoveryDocumentAsync();
if (discoveryDocument?.TokenEndpoint == null)
{
throw new InvalidOperationException("Token endpoint not found in discovery document");
}
var client = HttpClientFactory.CreateClient();
var content = new FormUrlEncodedContent(BuildTokenRequestParameters(code, config, codeVerifier));
var response = await client.PostAsync(discoveryDocument.TokenEndpoint, content);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
}
/// <summary>
/// Build the token request parameters
/// </summary>
protected virtual Dictionary<string, string> BuildTokenRequestParameters(string code, ProviderConfiguration config,
string? codeVerifier)
{
var parameters = new Dictionary<string, string>
{
{ "client_id", config.ClientId },
{ "code", code },
{ "grant_type", "authorization_code" },
{ "redirect_uri", config.RedirectUri }
};
if (!string.IsNullOrEmpty(config.ClientSecret))
{
parameters.Add("client_secret", config.ClientSecret);
}
if (!string.IsNullOrEmpty(codeVerifier))
{
parameters.Add("code_verifier", codeVerifier);
}
return parameters;
}
/// <summary>
/// Validates and extracts information from an ID token
/// </summary>
protected virtual OidcUserInfo ValidateAndExtractIdToken(string idToken,
TokenValidationParameters validationParameters)
{
var handler = new JwtSecurityTokenHandler();
handler.ValidateToken(idToken, validationParameters, out _);
var jwtToken = handler.ReadJwtToken(idToken);
// Extract standard claims
var userId = jwtToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
var email = jwtToken.Claims.FirstOrDefault(c => c.Type == "email")?.Value;
var emailVerified = jwtToken.Claims.FirstOrDefault(c => c.Type == "email_verified")?.Value == "true";
var name = jwtToken.Claims.FirstOrDefault(c => c.Type == "name")?.Value;
var givenName = jwtToken.Claims.FirstOrDefault(c => c.Type == "given_name")?.Value;
var familyName = jwtToken.Claims.FirstOrDefault(c => c.Type == "family_name")?.Value;
var preferredUsername = jwtToken.Claims.FirstOrDefault(c => c.Type == "preferred_username")?.Value;
var picture = jwtToken.Claims.FirstOrDefault(c => c.Type == "picture")?.Value;
// Determine preferred username - try different options
var username = preferredUsername;
if (string.IsNullOrEmpty(username))
{
// Fall back to email local part if no preferred username
username = !string.IsNullOrEmpty(email) ? email.Split('@')[0] : null;
}
return new OidcUserInfo
{
UserId = userId,
Email = email,
EmailVerified = emailVerified,
FirstName = givenName ?? "",
LastName = familyName ?? "",
DisplayName = name ?? $"{givenName} {familyName}".Trim(),
PreferredUsername = username ?? "",
ProfilePictureUrl = picture,
Provider = ProviderName
};
}
/// <summary>
/// Creates a challenge and session for an authenticated user
/// Also creates or updates the account connection
/// </summary>
public async Task<Challenge> CreateChallengeForUserAsync(
OidcUserInfo userInfo,
Account.Account account,
HttpContext request,
string deviceId
)
{
// Create or update the account connection
var connection = await Db.AccountConnections
.FirstOrDefaultAsync(c => c.Provider == ProviderName &&
c.ProvidedIdentifier == userInfo.UserId &&
c.AccountId == account.Id
);
if (connection is null)
{
connection = new AccountConnection
{
Provider = ProviderName,
ProvidedIdentifier = userInfo.UserId ?? "",
AccessToken = userInfo.AccessToken,
RefreshToken = userInfo.RefreshToken,
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
AccountId = account.Id
};
await Db.AccountConnections.AddAsync(connection);
}
// Create a challenge that's already completed
var now = SystemClock.Instance.GetCurrentInstant();
var challenge = new Challenge
{
ExpiredAt = now.Plus(Duration.FromHours(1)),
StepTotal = await auth.DetectChallengeRisk(request.Request, account),
Type = ChallengeType.Oidc,
Platform = ChallengePlatform.Unidentified,
Audiences = [ProviderName],
Scopes = ["*"],
AccountId = account.Id,
DeviceId = deviceId,
IpAddress = request.Connection.RemoteIpAddress?.ToString() ?? null,
UserAgent = request.Request.Headers.UserAgent,
};
challenge.StepRemain--;
if (challenge.StepRemain < 0) challenge.StepRemain = 0;
await Db.AuthChallenges.AddAsync(challenge);
await Db.SaveChangesAsync();
return challenge;
}
}
/// <summary>
/// Provider configuration from app settings
/// </summary>
public class ProviderConfiguration
{
public string ClientId { get; set; } = "";
public string ClientSecret { get; set; } = "";
public string RedirectUri { get; set; } = "";
}
/// <summary>
/// OIDC Discovery Document
/// </summary>
public class OidcDiscoveryDocument
{
[JsonPropertyName("authorization_endpoint")]
public string? AuthorizationEndpoint { get; set; }
[JsonPropertyName("token_endpoint")] public string? TokenEndpoint { get; set; }
[JsonPropertyName("userinfo_endpoint")]
public string? UserinfoEndpoint { get; set; }
[JsonPropertyName("jwks_uri")] public string? JwksUri { get; set; }
}
/// <summary>
/// Response from the token endpoint
/// </summary>
public class OidcTokenResponse
{
[JsonPropertyName("access_token")] public string? AccessToken { get; set; }
[JsonPropertyName("token_type")] public string? TokenType { get; set; }
[JsonPropertyName("expires_in")] public int ExpiresIn { get; set; }
[JsonPropertyName("refresh_token")] public string? RefreshToken { get; set; }
[JsonPropertyName("id_token")] public string? IdToken { get; set; }
}
/// <summary>
/// Data received in the callback from an OIDC provider
/// </summary>
public class OidcCallbackData
{
public string Code { get; set; } = "";
public string IdToken { get; set; } = "";
public string? State { get; set; }
public string? RawData { get; set; }
}

View File

@ -1,189 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DysonNetwork.Sphere.Auth.OpenId;
/// <summary>
/// Represents the state parameter used in OpenID Connect flows.
/// Handles serialization and deserialization of the state parameter.
/// </summary>
public class OidcState
{
/// <summary>
/// The type of OIDC flow (login or connect).
/// </summary>
public OidcFlowType FlowType { get; set; }
/// <summary>
/// The account ID (for connect flow).
/// </summary>
public Guid? AccountId { get; set; }
/// <summary>
/// The OIDC provider name.
/// </summary>
public string? Provider { get; set; }
/// <summary>
/// The nonce for CSRF protection.
/// </summary>
public string? Nonce { get; set; }
/// <summary>
/// The device ID for the authentication request.
/// </summary>
public string? DeviceId { get; set; }
/// <summary>
/// The return URL after authentication (for login flow).
/// </summary>
public string? ReturnUrl { get; set; }
/// <summary>
/// Creates a new OidcState for a connection flow.
/// </summary>
public static OidcState ForConnection(Guid accountId, string provider, string nonce, string? deviceId = null)
{
return new OidcState
{
FlowType = OidcFlowType.Connect,
AccountId = accountId,
Provider = provider,
Nonce = nonce,
DeviceId = deviceId
};
}
/// <summary>
/// Creates a new OidcState for a login flow.
/// </summary>
public static OidcState ForLogin(string returnUrl = "/", string? deviceId = null)
{
return new OidcState
{
FlowType = OidcFlowType.Login,
ReturnUrl = returnUrl,
DeviceId = deviceId
};
}
/// <summary>
/// The version of the state format.
/// </summary>
public int Version { get; set; } = 1;
/// <summary>
/// Serializes the state to a JSON string for use in OIDC flows.
/// </summary>
public string Serialize()
{
return JsonSerializer.Serialize(this, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
}
/// <summary>
/// Attempts to parse a state string into an OidcState object.
/// </summary>
public static bool TryParse(string? stateString, out OidcState? state)
{
state = null;
if (string.IsNullOrEmpty(stateString))
return false;
try
{
// First try to parse as JSON
try
{
state = JsonSerializer.Deserialize<OidcState>(stateString);
return state != null;
}
catch (JsonException)
{
// Not a JSON string, try legacy format for backward compatibility
return TryParseLegacyFormat(stateString, out state);
}
}
catch
{
return false;
}
}
private static bool TryParseLegacyFormat(string stateString, out OidcState? state)
{
state = null;
var parts = stateString.Split('|');
// Check for connection flow format: {accountId}|{provider}|{nonce}|{deviceId}|connect
if (parts.Length >= 5 &&
Guid.TryParse(parts[0], out var accountId) &&
string.Equals(parts[^1], "connect", StringComparison.OrdinalIgnoreCase))
{
state = new OidcState
{
FlowType = OidcFlowType.Connect,
AccountId = accountId,
Provider = parts[1],
Nonce = parts[2],
DeviceId = parts.Length >= 4 && !string.IsNullOrEmpty(parts[3]) ? parts[3] : null
};
return true;
}
// Check for login flow format: {returnUrl}|{deviceId}|login
if (parts.Length >= 2 &&
parts.Length <= 3 &&
(parts.Length < 3 || string.Equals(parts[^1], "login", StringComparison.OrdinalIgnoreCase)))
{
state = new OidcState
{
FlowType = OidcFlowType.Login,
ReturnUrl = parts[0],
DeviceId = parts.Length >= 2 && !string.IsNullOrEmpty(parts[1]) ? parts[1] : null
};
return true;
}
// Legacy format support (for backward compatibility)
if (parts.Length == 1)
{
state = new OidcState
{
FlowType = OidcFlowType.Login,
ReturnUrl = parts[0],
DeviceId = null
};
return true;
}
return false;
}
}
/// <summary>
/// Represents the type of OIDC flow.
/// </summary>
public enum OidcFlowType
{
/// <summary>
/// Login or registration flow.
/// </summary>
Login,
/// <summary>
/// Account connection flow.
/// </summary>
Connect
}

View File

@ -1,49 +0,0 @@
namespace DysonNetwork.Sphere.Auth.OpenId;
/// <summary>
/// Represents the user information from an OIDC provider
/// </summary>
public class OidcUserInfo
{
public string? UserId { get; set; }
public string? Email { get; set; }
public bool EmailVerified { get; set; }
public string FirstName { get; set; } = "";
public string LastName { get; set; } = "";
public string DisplayName { get; set; } = "";
public string PreferredUsername { get; set; } = "";
public string? ProfilePictureUrl { get; set; }
public string Provider { get; set; } = "";
public string? RefreshToken { get; set; }
public string? AccessToken { get; set; }
public Dictionary<string, object> ToMetadata()
{
var metadata = new Dictionary<string, object>();
if (!string.IsNullOrWhiteSpace(UserId))
metadata["user_id"] = UserId;
if (!string.IsNullOrWhiteSpace(Email))
metadata["email"] = Email;
metadata["email_verified"] = EmailVerified;
if (!string.IsNullOrWhiteSpace(FirstName))
metadata["first_name"] = FirstName;
if (!string.IsNullOrWhiteSpace(LastName))
metadata["last_name"] = LastName;
if (!string.IsNullOrWhiteSpace(DisplayName))
metadata["display_name"] = DisplayName;
if (!string.IsNullOrWhiteSpace(PreferredUsername))
metadata["preferred_username"] = PreferredUsername;
if (!string.IsNullOrWhiteSpace(ProfilePictureUrl))
metadata["profile_picture_url"] = ProfilePictureUrl;
return metadata;
}
}

View File

@ -1,69 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Developer;
using NodaTime;
using Point = NetTopologySuite.Geometries.Point;
namespace DysonNetwork.Sphere.Auth;
public class Session : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string? Label { get; set; }
public Instant? LastGrantedAt { get; set; }
public Instant? ExpiredAt { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!;
public Guid ChallengeId { get; set; }
public Challenge Challenge { get; set; } = null!;
public Guid? AppId { get; set; }
public CustomApp? App { get; set; }
}
public enum ChallengeType
{
Login,
OAuth, // Trying to authorize other platforms
Oidc // Trying to connect other platforms
}
public enum ChallengePlatform
{
Unidentified,
Web,
Ios,
Android,
MacOs,
Windows,
Linux
}
public class Challenge : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public Instant? ExpiredAt { get; set; }
public int StepRemain { get; set; }
public int StepTotal { get; set; }
public int FailedAttempts { get; set; }
public ChallengePlatform Platform { get; set; } = ChallengePlatform.Unidentified;
public ChallengeType Type { get; set; } = ChallengeType.Login;
[Column(TypeName = "jsonb")] public List<Guid> BlacklistFactors { get; set; } = new();
[Column(TypeName = "jsonb")] public List<string> Audiences { get; set; } = new();
[Column(TypeName = "jsonb")] public List<string> Scopes { get; set; } = new();
[MaxLength(128)] public string? IpAddress { get; set; }
[MaxLength(512)] public string? UserAgent { get; set; }
[MaxLength(256)] public string? DeviceId { get; set; }
[MaxLength(1024)] public string? Nonce { get; set; }
public Point? Location { get; set; }
public Guid AccountId { get; set; }
[JsonIgnore] public Account.Account Account { get; set; } = null!;
public Challenge Normalize()
{
if (StepRemain == 0 && BlacklistFactors.Count == 0) StepRemain = StepTotal;
return this;
}
}

View File

@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;
using DysonNetwork.Common.Models;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Authorization;
@ -10,7 +11,7 @@ namespace DysonNetwork.Sphere.Chat;
[ApiController]
[Route("/chat")]
public partial class ChatController(AppDatabase db, ChatService cs, ChatRoomService crs) : ControllerBase
public partial class ChatController(AppDatabase db, ChatService cs, ChatRoomService crs, AccountClient accountClient) : ControllerBase
{
public class MarkMessageReadRequest
{

View File

@ -1,144 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Storage;
using NodaTime;
namespace DysonNetwork.Sphere.Chat;
public enum ChatRoomType
{
Group,
DirectMessage
}
public class ChatRoom : ModelBase, IIdentifiedResource
{
public Guid Id { get; set; }
[MaxLength(1024)] public string? Name { get; set; }
[MaxLength(4096)] public string? Description { get; set; }
public ChatRoomType Type { get; set; }
public bool IsCommunity { get; set; }
public bool IsPublic { get; set; }
// Outdated fields, for backward compability
[MaxLength(32)] public string? PictureId { get; set; }
[MaxLength(32)] public string? BackgroundId { get; set; }
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
[JsonIgnore] public ICollection<ChatMember> Members { get; set; } = new List<ChatMember>();
public Guid? RealmId { get; set; }
public Realm.Realm? Realm { get; set; }
[NotMapped]
[JsonPropertyName("members")]
public ICollection<ChatMemberTransmissionObject> DirectMembers { get; set; } =
new List<ChatMemberTransmissionObject>();
public string ResourceIdentifier => $"chatroom/{Id}";
}
public abstract class ChatMemberRole
{
public const int Owner = 100;
public const int Moderator = 50;
public const int Member = 0;
}
public enum ChatMemberNotify
{
All,
Mentions,
None
}
public enum ChatTimeoutCauseType
{
ByModerator = 0,
BySlowMode = 1,
}
public class ChatTimeoutCause
{
public ChatTimeoutCauseType Type { get; set; }
public Guid? SenderId { get; set; }
}
public class ChatMember : ModelBase
{
public Guid Id { get; set; }
public Guid ChatRoomId { get; set; }
public ChatRoom ChatRoom { get; set; } = null!;
public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!;
[MaxLength(1024)] public string? Nick { get; set; }
public int Role { get; set; } = ChatMemberRole.Member;
public ChatMemberNotify Notify { get; set; } = ChatMemberNotify.All;
public Instant? LastReadAt { get; set; }
public Instant? JoinedAt { get; set; }
public Instant? LeaveAt { get; set; }
public bool IsBot { get; set; } = false;
/// <summary>
/// The break time is the user doesn't receive any message from this member for a while.
/// Expect mentioned him or her.
/// </summary>
public Instant? BreakUntil { get; set; }
/// <summary>
/// The timeout is the user can't send any message.
/// Set by the moderator of the chat room.
/// </summary>
public Instant? TimeoutUntil { get; set; }
/// <summary>
/// The timeout cause is the reason why the user is timeout.
/// </summary>
[Column(TypeName = "jsonb")] public ChatTimeoutCause? TimeoutCause { get; set; }
}
public class ChatMemberTransmissionObject : ModelBase
{
public Guid Id { get; set; }
public Guid ChatRoomId { get; set; }
public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!;
[MaxLength(1024)] public string? Nick { get; set; }
public int Role { get; set; } = ChatMemberRole.Member;
public ChatMemberNotify Notify { get; set; } = ChatMemberNotify.All;
public Instant? JoinedAt { get; set; }
public Instant? LeaveAt { get; set; }
public bool IsBot { get; set; } = false;
public Instant? BreakUntil { get; set; }
public Instant? TimeoutUntil { get; set; }
public ChatTimeoutCause? TimeoutCause { get; set; }
public static ChatMemberTransmissionObject FromEntity(ChatMember member)
{
return new ChatMemberTransmissionObject
{
Id = member.Id,
ChatRoomId = member.ChatRoomId,
AccountId = member.AccountId,
Account = member.Account,
Nick = member.Nick,
Role = member.Role,
Notify = member.Notify,
JoinedAt = member.JoinedAt,
LeaveAt = member.LeaveAt,
IsBot = member.IsBot,
BreakUntil = member.BreakUntil,
TimeoutUntil = member.TimeoutUntil,
TimeoutCause = member.TimeoutCause,
CreatedAt = member.CreatedAt,
UpdatedAt = member.UpdatedAt,
DeletedAt = member.DeletedAt
};
}
}

View File

@ -1,7 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Common.Models;
using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Realm;
@ -23,7 +23,8 @@ public class ChatRoomController(
NotificationService nty,
RelationshipService rels,
IStringLocalizer<NotificationResource> localizer,
AccountEventService aes
PassClient passClient
) : ControllerBase
{
[HttpGet("{id:guid}")]
@ -36,7 +37,8 @@ public class ChatRoomController(
if (chatRoom is null) return NotFound();
if (chatRoom.Type != ChatRoomType.DirectMessage) return Ok(chatRoom);
if (HttpContext.Items["CurrentUser"] is Account.Account currentUser)
var currentUser = await passClient.GetAccountByIdAsync(User.GetUserId());
if (currentUser is not null)
chatRoom = await crs.LoadDirectMessageMembers(chatRoom, currentUser.Id);
return Ok(chatRoom);
@ -46,8 +48,8 @@ public class ChatRoomController(
[Authorize]
public async Task<ActionResult<List<ChatRoom>>> ListJoinedChatRooms()
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized();
var currentUser = await passClient.GetAccountByIdAsync(User.GetUserId());
if (currentUser is null) return Unauthorized();
var userId = currentUser.Id;
var chatRooms = await db.ChatMembers
@ -72,10 +74,10 @@ public class ChatRoomController(
[Authorize]
public async Task<ActionResult<ChatRoom>> CreateDirectMessage([FromBody] DirectMessageRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized();
var currentUser = await passClient.GetAccountByIdAsync(User.GetUserId());
if (currentUser is null) return Unauthorized();
var relatedUser = await db.Accounts.FindAsync(request.RelatedUserId);
var relatedUser = await passClient.GetAccountByIdAsync(request.RelatedUserId);
if (relatedUser is null)
return BadRequest("Related user was not found");
@ -134,8 +136,8 @@ public class ChatRoomController(
[Authorize]
public async Task<ActionResult<ChatRoom>> GetDirectChatRoom(Guid userId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized();
var currentUser = await passClient.GetAccountByIdAsync(User.GetUserId());
if (currentUser is null) return Unauthorized();
var room = await db.ChatRooms
.Include(c => c.Members)
@ -164,7 +166,8 @@ public class ChatRoomController(
[RequiredPermission("global", "chat.create")]
public async Task<ActionResult<ChatRoom>> CreateChatRoom(ChatRoomRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var currentUser = await passClient.GetAccountByIdAsync(User.GetUserId());
if (currentUser is null) return Unauthorized();
if (request.Name is null) return BadRequest("You cannot create a chat room without a name.");
var chatRoom = new ChatRoom
@ -236,7 +239,8 @@ public class ChatRoomController(
[HttpPatch("{id:guid}")]
public async Task<ActionResult<ChatRoom>> UpdateChatRoom(Guid id, [FromBody] ChatRoomRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var currentUser = await passClient.GetAccountByIdAsync(User.GetUserId());
if (currentUser is null) return Unauthorized();
var chatRoom = await db.ChatRooms
.Where(e => e.Id == id)
@ -321,7 +325,8 @@ public class ChatRoomController(
[HttpDelete("{id:guid}")]
public async Task<ActionResult> DeleteChatRoom(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var currentUser = await passClient.GetAccountByIdAsync(User.GetUserId());
if (currentUser is null) return Unauthorized();
var chatRoom = await db.ChatRooms
.Where(e => e.Id == id)
@ -356,8 +361,8 @@ public class ChatRoomController(
[Authorize]
public async Task<ActionResult<ChatMember>> GetRoomIdentity(Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
return Unauthorized();
var currentUser = await passClient.GetAccountByIdAsync(User.GetUserId());
if (currentUser is null) return Unauthorized();
var member = await db.ChatMembers
.Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId)
@ -375,7 +380,7 @@ public class ChatRoomController(
public async Task<ActionResult<List<ChatMember>>> ListMembers(Guid roomId, [FromQuery] int take = 20,
[FromQuery] int skip = 0, [FromQuery] bool withStatus = false, [FromQuery] string? status = null)
{
var currentUser = HttpContext.Items["CurrentUser"] as Account.Account;
var currentUser = await passClient.GetAccountByIdAsync(User.GetUserId());
var room = await db.ChatRooms
.FirstOrDefaultAsync(r => r.Id == roomId);
@ -448,10 +453,11 @@ public class ChatRoomController(
public async Task<ActionResult<ChatMember>> InviteMember(Guid roomId,
[FromBody] ChatMemberRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var currentUser = await passClient.GetAccountByIdAsync(User.GetUserId());
if (currentUser is null) return Unauthorized();
var userId = currentUser.Id;
var relatedUser = await db.Accounts.FindAsync(request.RelatedUserId);
var relatedUser = await passClient.GetAccountByIdAsync(request.RelatedUserId);
if (relatedUser is null) return BadRequest("Related user was not found");
if (await rels.HasRelationshipWithStatus(currentUser.Id, relatedUser.Id, RelationshipStatus.Blocked))
@ -519,7 +525,8 @@ public class ChatRoomController(
[Authorize]
public async Task<ActionResult<List<ChatMember>>> ListChatInvites()
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var currentUser = await passClient.GetAccountByIdAsync(User.GetUserId());
if (currentUser is null) return Unauthorized();
var userId = currentUser.Id;
var members = await db.ChatMembers
@ -544,7 +551,8 @@ public class ChatRoomController(
[Authorize]
public async Task<ActionResult<ChatRoom>> AcceptChatInvite(Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var currentUser = await passClient.GetAccountByIdAsync(User.GetUserId());
if (currentUser is null) return Unauthorized();
var userId = currentUser.Id;
var member = await db.ChatMembers
@ -571,7 +579,8 @@ public class ChatRoomController(
[Authorize]
public async Task<ActionResult> DeclineChatInvite(Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var currentUser = await passClient.GetAccountByIdAsync(User.GetUserId());
if (currentUser is null) return Unauthorized();
var userId = currentUser.Id;
var member = await db.ChatMembers
@ -600,7 +609,8 @@ public class ChatRoomController(
[FromBody] ChatMemberNotifyRequest request
)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var currentUser = await passClient.GetAccountByIdAsync(User.GetUserId());
if (currentUser is null) return Unauthorized();
var chatRoom = await db.ChatRooms
.Where(r => r.Id == roomId)
@ -629,7 +639,8 @@ public class ChatRoomController(
public async Task<ActionResult<ChatMember>> UpdateChatMemberRole(Guid roomId, Guid memberId, [FromBody] int newRole)
{
if (newRole >= ChatMemberRole.Owner) return BadRequest("Unable to set chat member to owner or greater role.");
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var currentUser = await passClient.GetAccountByIdAsync(User.GetUserId());
if (currentUser is null) return Unauthorized();
var chatRoom = await db.ChatRooms
.Where(r => r.Id == roomId)
@ -688,7 +699,8 @@ public class ChatRoomController(
[Authorize]
public async Task<ActionResult> RemoveChatMember(Guid roomId, Guid memberId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var currentUser = await passClient.GetAccountByIdAsync(User.GetUserId());
if (currentUser is null) return Unauthorized();
var chatRoom = await db.ChatRooms
.Where(r => r.Id == roomId)
@ -736,7 +748,8 @@ public class ChatRoomController(
[Authorize]
public async Task<ActionResult<ChatRoom>> JoinChatRoom(Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var currentUser = await passClient.GetAccountByIdAsync(User.GetUserId());
if (currentUser is null) return Unauthorized();
var chatRoom = await db.ChatRooms
.Where(r => r.Id == roomId)
@ -774,7 +787,8 @@ public class ChatRoomController(
[Authorize]
public async Task<ActionResult> LeaveChat(Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var currentUser = await passClient.GetAccountByIdAsync(User.GetUserId());
if (currentUser is null) return Unauthorized();
var member = await db.ChatMembers
.Where(m => m.AccountId == currentUser.Id)

View File

@ -1,3 +1,5 @@
using DysonNetwork.Common.Models;
using DysonNetwork.Common.Services;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using NodaTime;

View File

@ -1,5 +1,6 @@
using System.Text.RegularExpressions;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Pass.Features.Account;
using DysonNetwork.Common.Models;
using DysonNetwork.Sphere.Chat.Realtime;
using DysonNetwork.Sphere.Connection;
using DysonNetwork.Sphere.Storage;
@ -241,7 +242,6 @@ public partial class ChatService(
Priority = 10,
};
List<Account.Account> accountsToNotify = [];
foreach (var member in members)
{
scopedWs.SendPacketToAccount(member.AccountId, new WebSocketPacket
@ -260,15 +260,10 @@ public partial class ChatService(
}
else if (member.Notify == ChatMemberNotify.Mentions) continue;
accountsToNotify.Add(member.Account);
await scopedNty.SendNotification(member.Account, "invites.chats", notification.Title, null, notification.Content, actionUri: notification.Meta["action_uri"].ToString());
}
logger.LogInformation($"Trying to deliver message to {accountsToNotify.Count} accounts...");
// Only send notifications if there are accounts to notify
if (accountsToNotify.Count > 0)
await scopedNty.SendNotificationBatch(notification, accountsToNotify, save: false);
logger.LogInformation($"Delivered message to {accountsToNotify.Count} accounts.");
logger.LogInformation($"Delivered message to {members.Count} accounts.");
}
/// <summary>

View File

@ -1,67 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Chat;
public class Message : ModelBase, IIdentifiedResource
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Type { get; set; } = null!;
[MaxLength(4096)] public string? Content { get; set; }
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; }
[Column(TypeName = "jsonb")] public List<Guid>? MembersMentioned { get; set; }
[MaxLength(36)] public string Nonce { get; set; } = null!;
public Instant? EditedAt { get; set; }
[Column(TypeName = "jsonb")] public List<CloudFileReferenceObject> Attachments { get; set; } = [];
// Outdated fields, keep for backward compability
public ICollection<CloudFile> OutdatedAttachments { get; set; } = new List<CloudFile>();
public ICollection<MessageReaction> Reactions { get; set; } = new List<MessageReaction>();
public Guid? RepliedMessageId { get; set; }
public Message? RepliedMessage { get; set; }
public Guid? ForwardedMessageId { get; set; }
public Message? ForwardedMessage { get; set; }
public Guid SenderId { get; set; }
public ChatMember Sender { get; set; } = null!;
public Guid ChatRoomId { get; set; }
[JsonIgnore] public ChatRoom ChatRoom { get; set; } = null!;
public string ResourceIdentifier => $"message/{Id}";
}
public enum MessageReactionAttitude
{
Positive,
Neutral,
Negative,
}
public class MessageReaction : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid MessageId { get; set; }
[JsonIgnore] public Message Message { get; set; } = null!;
public Guid SenderId { get; set; }
public ChatMember Sender { get; set; } = null!;
[MaxLength(256)] public string Symbol { get; set; } = null!;
public MessageReactionAttitude Attitude { get; set; }
}
/// <summary>
/// The data model for updating the last read at field for chat member,
/// after the refactor of the unread system, this no longer stored in the database.
/// Not only used for the data transmission object
/// </summary>
[NotMapped]
public class MessageReadReceipt
{
public Guid SenderId { get; set; }
}

View File

@ -36,7 +36,7 @@ public interface IRealtimeService
/// <param name="sessionId">The session identifier</param>
/// <param name="isAdmin">The user is the admin of session</param>
/// <returns>User-specific token for the session</returns>
string GetUserToken(Account.Account account, string sessionId, bool isAdmin = false);
string GetUserToken(Guid accountId, string accountName, string sessionId, bool isAdmin = false);
/// <summary>
/// Processes incoming webhook requests from the realtime service provider

View File

@ -4,6 +4,8 @@ using Livekit.Server.Sdk.Dotnet;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using System.Text.Json;
using DysonNetwork.Common.Models;
using DysonNetwork.Common.Services;
namespace DysonNetwork.Sphere.Chat.Realtime;
@ -126,7 +128,7 @@ public class LivekitRealtimeService : IRealtimeService
Room = sessionId
})
.WithMetadata(JsonSerializer.Serialize(new Dictionary<string, string>
{ { "account_id", account.Id.ToString() } }))
{ { "account_id", accountId.ToString() } }))
.WithTtl(TimeSpan.FromHours(1));
return token.ToJwt();
}

View File

@ -1,50 +0,0 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Chat.Realtime;
using NodaTime;
namespace DysonNetwork.Sphere.Chat;
public class RealtimeCall : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public Instant? EndedAt { get; set; }
public Guid SenderId { get; set; }
public ChatMember Sender { get; set; } = null!;
public Guid RoomId { get; set; }
public ChatRoom Room { get; set; } = null!;
/// <summary>
/// Provider name (e.g., "cloudflare", "agora", "twilio")
/// </summary>
public string? ProviderName { get; set; }
/// <summary>
/// Service provider's session identifier
/// </summary>
public string? SessionId { get; set; }
/// <summary>
/// JSONB column containing provider-specific configuration
/// </summary>
[Column(name: "upstream", TypeName = "jsonb")]
public string? UpstreamConfigJson { get; set; }
/// <summary>
/// Deserialized upstream configuration
/// </summary>
[NotMapped]
public Dictionary<string, object> UpstreamConfig
{
get => string.IsNullOrEmpty(UpstreamConfigJson)
? new Dictionary<string, object>()
: JsonSerializer.Deserialize<Dictionary<string, object>>(UpstreamConfigJson) ?? new Dictionary<string, object>();
set => UpstreamConfigJson = value.Count > 0
? JsonSerializer.Serialize(value)
: null;
}
}

View File

@ -1,3 +1,4 @@
using DysonNetwork.Common.Models;
using DysonNetwork.Sphere.Chat.Realtime;
using Livekit.Server.Sdk.Dotnet;
using Microsoft.AspNetCore.Authorization;
@ -163,7 +164,8 @@ public class RealtimeCallController(
[Authorize]
public async Task<ActionResult<RealtimeCall>> EndCall(Guid roomId)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var currentUser = await passClient.GetAccountByIdAsync(User.GetUserId());
if (currentUser is null) return Unauthorized();
var member = await db.ChatMembers
.Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId)

View File

@ -38,19 +38,7 @@ public class AutoCompletionController(AppDatabase db)
private async Task<List<CompletionItem>> GetAccountCompletions(string searchTerm)
{
return await db.Accounts
.Where(a => EF.Functions.ILike(a.Name, $"%{searchTerm}%"))
.OrderBy(a => a.Name)
.Take(10)
.Select(a => new CompletionItem
{
Id = a.Id.ToString(),
DisplayName = a.Name,
SecondaryText = a.Nick,
Type = "account",
Data = a
})
.ToListAsync();
return await passClient.GetAccountCompletions(searchTerm);
}
private async Task<List<CompletionItem>> GetStickerCompletions(string searchTerm)

View File

@ -1,4 +1,5 @@
using System.Net.WebSockets;
using DysonNetwork.Common.Models;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
@ -42,7 +43,7 @@ public class MessageReadHandler(
return;
}
var sender = await crs.GetRoomMember(currentUser.Id, request.ChatRoomId);
var sender = await crs.GetRoomMember(currentUserId, request.ChatRoomId);
if (sender is null)
{
await socket.SendAsync(

View File

@ -33,7 +33,7 @@ public class MessageTypingHandler(ChatRoomService crs) : IWebSocketPacketHandler
return;
}
var sender = await crs.GetRoomMember(currentUser.Id, request.ChatRoomId);
var sender = await crs.GetRoomMember(currentUserId, request.ChatRoomId);
if (sender is null)
{
await socket.SendAsync(

View File

@ -32,7 +32,7 @@ public class MessagesSubscribeHandler(ChatRoomService crs) : IWebSocketPacketHan
return;
}
var sender = await crs.GetRoomMember(currentUser.Id, request.ChatRoomId);
var sender = await crs.GetRoomMember(currentUserId, request.ChatRoomId);
if (sender is null)
{
await socket.SendAsync(

View File

@ -8,7 +8,7 @@ public class MessagesUnsubscribeHandler() : IWebSocketPacketHandler
public string PacketType => "messages.unsubscribe";
public Task HandleAsync(
Account.Account currentUser,
Guid currentUserId,
string deviceId,
WebSocketPacket packet,
WebSocket socket,

View File

@ -5,5 +5,5 @@ namespace DysonNetwork.Sphere.Connection;
public interface IWebSocketPacketHandler
{
string PacketType { get; }
Task HandleAsync(Account.Account currentUser, string deviceId, WebSocketPacket packet, WebSocket socket, WebSocketService srv);
Task HandleAsync(Guid currentUserId, string deviceId, WebSocketPacket packet, WebSocket socket, WebSocketService srv);
}

View File

@ -104,7 +104,8 @@ public class WebFeedController(WebFeedService webFeed, PublisherService ps) : Co
[Authorize]
public async Task<ActionResult> Scrap([FromRoute] string pubName, Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var currentUser = await passClient.GetAccountByIdAsync(User.GetUserId());
if (currentUser is null) return Unauthorized();
var publisher = await ps.GetPublisherByName(pubName);
if (publisher is null) return NotFound();

View File

@ -1,6 +1,7 @@
using System.Globalization;
using AngleSharp;
using AngleSharp.Dom;
using DysonNetwork.Common.Services;
using DysonNetwork.Sphere.Storage;
using HtmlAgilityPack;

View File

@ -91,7 +91,7 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
);
var packet = WebSocketPacket.FromBytes(buffer[..receiveResult.Count]);
_ = ws.HandlePacket(currentUser, connectionKey.DeviceId, packet, webSocket);
_ = ws.HandlePacket(currentUserId, connectionKey.DeviceId, packet, webSocket);
}
}
catch (OperationCanceledException)

View File

@ -111,7 +111,7 @@ public class WebSocketService
{
if (_handlerMap.TryGetValue(packet.Type, out var handler))
{
await handler.HandleAsync(currentUser, deviceId, packet, socket, this);
await handler.HandleAsync(currentUserId, deviceId, packet, socket, this);
return;
}

View File

@ -1,139 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Sphere.Data.Migrations
{
/// <inheritdoc />
public partial class AddOidcProviderSupport : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "remarks",
table: "custom_app_secrets",
newName: "description");
migrationBuilder.AddColumn<bool>(
name: "allow_offline_access",
table: "custom_apps",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "allowed_grant_types",
table: "custom_apps",
type: "character varying(256)",
maxLength: 256,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "allowed_scopes",
table: "custom_apps",
type: "character varying(256)",
maxLength: 256,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "client_uri",
table: "custom_apps",
type: "character varying(1024)",
maxLength: 1024,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "logo_uri",
table: "custom_apps",
type: "character varying(4096)",
maxLength: 4096,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "post_logout_redirect_uris",
table: "custom_apps",
type: "character varying(4096)",
maxLength: 4096,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "redirect_uris",
table: "custom_apps",
type: "character varying(4096)",
maxLength: 4096,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<bool>(
name: "require_pkce",
table: "custom_apps",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "is_oidc",
table: "custom_app_secrets",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.CreateIndex(
name: "ix_custom_app_secrets_secret",
table: "custom_app_secrets",
column: "secret",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_custom_app_secrets_secret",
table: "custom_app_secrets");
migrationBuilder.DropColumn(
name: "allow_offline_access",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "allowed_grant_types",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "allowed_scopes",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "client_uri",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "logo_uri",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "post_logout_redirect_uris",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "redirect_uris",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "require_pkce",
table: "custom_apps");
migrationBuilder.DropColumn(
name: "is_oidc",
table: "custom_app_secrets");
migrationBuilder.RenameColumn(
name: "description",
table: "custom_app_secrets",
newName: "remarks");
}
}
}

View File

@ -1,49 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Sphere.Data.Migrations
{
/// <inheritdoc />
public partial class AuthSessionWithApp : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "app_id",
table: "auth_sessions",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_auth_sessions_app_id",
table: "auth_sessions",
column: "app_id");
migrationBuilder.AddForeignKey(
name: "fk_auth_sessions_custom_apps_app_id",
table: "auth_sessions",
column: "app_id",
principalTable: "custom_apps",
principalColumn: "id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_auth_sessions_custom_apps_app_id",
table: "auth_sessions");
migrationBuilder.DropIndex(
name: "ix_auth_sessions_app_id",
table: "auth_sessions");
migrationBuilder.DropColumn(
name: "app_id",
table: "auth_sessions");
}
}
}

View File

@ -3,7 +3,6 @@ using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Connection.WebReader;
using DysonNetwork.Sphere.Developer;

View File

@ -1,4 +1,4 @@
using DysonNetwork.Sphere.Account;
using DysonNetwork.Common.Models;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore.Migrations;

View File

@ -1,69 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using NodaTime;
namespace DysonNetwork.Sphere.Developer;
public enum CustomAppStatus
{
Developing,
Staging,
Production,
Suspended
}
public class CustomApp : ModelBase, IIdentifiedResource
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Slug { get; set; } = null!;
[MaxLength(1024)] public string Name { get; set; } = null!;
[MaxLength(4096)] public string? Description { get; set; }
public CustomAppStatus Status { get; set; } = CustomAppStatus.Developing;
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
[Column(TypeName = "jsonb")] public CustomAppOauthConfig? OauthConfig { get; set; }
[Column(TypeName = "jsonb")] public CustomAppLinks? Links { get; set; }
[JsonIgnore] public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
public Guid PublisherId { get; set; }
public Publisher.Publisher Developer { get; set; } = null!;
[NotMapped] public string ResourceIdentifier => "custom-app/" + Id;
}
public class CustomAppLinks : ModelBase
{
[MaxLength(8192)] public string? HomePage { get; set; }
[MaxLength(8192)] public string? PrivacyPolicy { get; set; }
[MaxLength(8192)] public string? TermsOfService { get; set; }
}
public class CustomAppOauthConfig : ModelBase
{
[MaxLength(1024)] public string? ClientUri { get; set; }
[MaxLength(4096)] public string[] RedirectUris { get; set; } = [];
[MaxLength(4096)] public string[]? PostLogoutRedirectUris { get; set; }
[MaxLength(256)] public string[]? AllowedScopes { get; set; } = ["openid", "profile", "email"];
[MaxLength(256)] public string[] AllowedGrantTypes { get; set; } = ["authorization_code", "refresh_token"];
public bool RequirePkce { get; set; } = true;
public bool AllowOfflineAccess { get; set; } = false;
}
public class CustomAppSecret : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Secret { get; set; } = null!;
[MaxLength(4096)] public string? Description { get; set; } = null!;
public Instant? ExpiredAt { get; set; }
public bool IsOidc { get; set; } = false; // Indicates if this secret is for OIDC/OAuth
public Guid AppId { get; set; }
public CustomApp App { get; set; } = null!;
}

View File

@ -1,5 +1,5 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Publisher;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -47,7 +47,7 @@ public class CustomAppController(CustomAppService customApps, PublisherService p
[Authorize]
public async Task<IActionResult> CreateApp([FromRoute] string pubName, [FromBody] CustomAppRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not Pass.Models.Account currentUser) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Slug))
return BadRequest("Name and slug are required");
@ -79,7 +79,7 @@ public class CustomAppController(CustomAppService customApps, PublisherService p
[FromBody] CustomAppRequest request
)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
if (HttpContext.Items["CurrentUser"] is not Pass.Models.Account currentUser) return Unauthorized();
var publisher = await ps.GetPublisherByName(pubName);
if (publisher is null) return NotFound();

View File

@ -1,4 +1,4 @@
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Publisher;
using Microsoft.AspNetCore.Authorization;

View File

@ -7,7 +7,7 @@ namespace DysonNetwork.Sphere.Discovery;
public class DiscoveryController(DiscoveryService discoveryService) : ControllerBase
{
[HttpGet("realms")]
public Task<List<Realm.Realm>> GetPublicRealms(
public Task<List<Common.Models.Realm>> GetPublicRealms(
[FromQuery] string? query,
[FromQuery] List<string>? tags,
[FromQuery] int take = 10,

View File

@ -3,7 +3,7 @@ namespace DysonNetwork.Sphere.Discovery;
public class DiscoveryService(AppDatabase appDatabase)
{
public Task<List<Realm.Realm>> GetPublicRealmsAsync(string? query,
public Task<List<Common.Models.Realm>> GetPublicRealmsAsync(string? query,
List<string>? tags,
int take = 10,
int offset = 0,

View File

@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<UserSecretsId>cfdec342-d2f2-4a86-800b-93f0a0e4abde</UserSecretsId>
<SatelliteResourceLanguages>en-US;zh-Hans</SatelliteResourceLanguages>
</PropertyGroup>
@ -17,9 +17,9 @@
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.3.0" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" />
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.1" />
<PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="FFMpegCore" Version="5.2.0" />
@ -27,8 +27,9 @@
<PackageReference Include="Livekit.Server.Sdk.Dotnet" Version="1.0.8" />
<PackageReference Include="MailKit" Version="4.11.0" />
<PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -44,31 +45,36 @@
<PackageReference Include="NetVips.Native.osx-arm64" Version="8.16.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NodaTime" Version="3.2.2" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
<PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageReference Include="prometheus-net.AspNetCore.HealthChecks" Version="8.2.1" />
<PackageReference Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
<PackageReference Include="prometheus-net.EntityFramework" Version="0.9.5" />
<PackageReference Include="prometheus-net.SystemMetrics" Version="3.1.0" />
<PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="Quartz.AspNetCore" Version="3.14.0" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
<PackageReference Include="SkiaSharp" Version="2.88.9" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.9" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.9" />
<PackageReference Include="SkiaSharp.NativeAssets.macOS" Version="2.88.9" />
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
<PackageReference Include="StackExchange.Redis" Version="2.8.41" />
<PackageReference Include="StackExchange.Redis.Extensions.AspNetCore" Version="11.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.0" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="8.1.0" />
@ -85,6 +91,12 @@
<ItemGroup>
<Folder Include="Migrations\" />
<Folder Include="Discovery\" />
<Folder Include="Services\PassClient\" />
<ProjectReference Include="..\DysonNetwork.Pass\DysonNetwork.Pass.csproj" />
</ItemGroup>
<ItemGroup>
</ItemGroup>
<ItemGroup>
@ -92,14 +104,7 @@
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>SharedResource.Designer.cs</LastGenOutput>
</EmbeddedResource>
<EmbeddedResource Update="Resources\AccountEventResource.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>AccountEventResource.Designer.cs</LastGenOutput>
</EmbeddedResource>
<EmbeddedResource Update="Resources\Localization\AccountEventResource.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>AccountEventResource.Designer.cs</LastGenOutput>
</EmbeddedResource>
<EmbeddedResource Update="Resources\Localization\SharedResource.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>SharedResource.Designer.cs</LastGenOutput>
@ -119,9 +124,7 @@
<EmbeddedResource Update="Resources\Localization\SharedResource.zh-hans.resx">
<DependentUpon>SharedResource.resx</DependentUpon>
</EmbeddedResource>
<EmbeddedResource Update="Resources\Localization\AccountEventResource.zh-hans.resx">
<DependentUpon>AccountEventResource.resx</DependentUpon>
</EmbeddedResource>
<EmbeddedResource Update="Resources\Localization\EmailResource.zh-hans.resx">
<DependentUpon>EmailResource.resx</DependentUpon>
</EmbeddedResource>
@ -131,27 +134,15 @@
</ItemGroup>
<ItemGroup>
<Compile Update="Resources\Localization\AccountEventResource.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>AccountEventResource.resx</DependentUpon>
</Compile>
<Compile Update="Resources\Localization\SharedResource.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>SharedResource.resx</DependentUpon>
</Compile>
<Compile Update="Resources\Pages\Emails\LandingEmailResource.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>LandingEmailResource.resx</DependentUpon>
</Compile>
<Compile Update="Resources\Localization\NotificationResource.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>NotificationResource.resx</DependentUpon>
</Compile>
<Compile Remove="Auth\AppleAuthController.cs" />
</ItemGroup>
<ItemGroup>

View File

@ -1,6 +0,0 @@
namespace DysonNetwork.Sphere.Localization;
public class AccountEventResource
{
}

View File

@ -3,7 +3,6 @@ using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;

View File

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore.Migrations;
using NetTopologySuite.Geometries;

View File

@ -3,7 +3,6 @@ using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;

View File

@ -3,7 +3,6 @@ using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;

View File

@ -3,7 +3,6 @@ using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;

View File

@ -3,7 +3,6 @@ using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;

View File

@ -3,7 +3,6 @@ using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;

View File

@ -3,7 +3,6 @@ using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;

View File

@ -3,7 +3,6 @@ using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;

View File

@ -3,7 +3,6 @@ using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;

View File

@ -3,7 +3,6 @@ using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;

File diff suppressed because it is too large Load Diff

View File

@ -1,61 +0,0 @@
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class BetterAuthFactor : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Dictionary<string, object>>(
name: "config",
table: "account_auth_factors",
type: "jsonb",
nullable: true);
migrationBuilder.AddColumn<Instant>(
name: "enabled_at",
table: "account_auth_factors",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<Instant>(
name: "expired_at",
table: "account_auth_factors",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "trustworthy",
table: "account_auth_factors",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "config",
table: "account_auth_factors");
migrationBuilder.DropColumn(
name: "enabled_at",
table: "account_auth_factors");
migrationBuilder.DropColumn(
name: "expired_at",
table: "account_auth_factors");
migrationBuilder.DropColumn(
name: "trustworthy",
table: "account_auth_factors");
}
}
}

View File

@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class AccountContactCanBePrimary : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "is_primary",
table: "account_contacts",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "is_primary",
table: "account_contacts");
}
}
}

View File

@ -3,7 +3,6 @@ using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;

Some files were not shown because too many files have changed in this diff Show More