♻️ Moved services & controllers to Pass
This commit is contained in:
		| @@ -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 Shared.Models.Account Account { get; set; } = null!; | ||||
| } | ||||
| @@ -1,178 +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; | ||||
| using DysonNetwork.Shared.Models; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Account; | ||||
|  | ||||
| [ApiController] | ||||
| [Route("/accounts")] | ||||
| public class AccountController( | ||||
|     AppDatabase db, | ||||
|     AuthService auth, | ||||
|     AccountService accounts, | ||||
|     AccountEventService events | ||||
| ) : ControllerBase | ||||
| { | ||||
|     [HttpGet("{name}")] | ||||
|     [ProducesResponseType<Shared.Models.Account>(StatusCodes.Status200OK)] | ||||
|     [ProducesResponseType(StatusCodes.Status404NotFound)] | ||||
|     public async Task<ActionResult<Shared.Models.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<Shared.Models.Account>(StatusCodes.Status200OK)] | ||||
|     [ProducesResponseType(StatusCodes.Status400BadRequest)] | ||||
|     public async Task<ActionResult<Shared.Models.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<Shared.Models.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(); | ||||
|     } | ||||
| } | ||||
| @@ -1,704 +0,0 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using DysonNetwork.Shared.Models; | ||||
| 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<Shared.Models.Account>(StatusCodes.Status200OK)] | ||||
|     public async Task<ActionResult<Shared.Models.Account>> GetCurrentIdentity() | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Shared.Models.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<Shared.Models.Account>> UpdateBasicInfo([FromBody] BasicInfoRequest request) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.Account currentUser) return Unauthorized(); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             await accounts.ActiveBadge(currentUser, id); | ||||
|             return Ok(); | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             return BadRequest(ex.Message); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,340 +0,0 @@ | ||||
| using System.Globalization; | ||||
| using DysonNetwork.Shared.Models; | ||||
| 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(Shared.Models.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(Shared.Models.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(Shared.Models.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(Shared.Models.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(Shared.Models.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(Shared.Models.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(); | ||||
|     } | ||||
| } | ||||
| @@ -1,88 +0,0 @@ | ||||
| using DysonNetwork.Shared.Models; | ||||
| using DysonNetwork.Sphere.Account.Proto; | ||||
| using Grpc.Core; | ||||
| using Google.Protobuf.WellKnownTypes; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using DysonNetwork.Sphere.Auth; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Account; | ||||
|  | ||||
| public class AccountGrpcService : DysonNetwork.Sphere.Account.Proto.AccountService.AccountServiceBase | ||||
| { | ||||
|     private readonly AppDatabase _db; | ||||
|     private readonly AuthService _auth; | ||||
|  | ||||
|     public AccountGrpcService(AppDatabase db, AuthService auth) | ||||
|     { | ||||
|         _db = db; | ||||
|         _auth = auth; | ||||
|     } | ||||
|  | ||||
|     public override async Task<AccountResponse> GetAccount(Empty request, ServerCallContext context) | ||||
|     { | ||||
|         var account = await GetAccountFromContext(context); | ||||
|         return ToAccountResponse(account); | ||||
|     } | ||||
|  | ||||
|     public override async Task<AccountResponse> UpdateAccount(UpdateAccountRequest request, ServerCallContext context) | ||||
|     { | ||||
|         var account = await GetAccountFromContext(context); | ||||
|  | ||||
|         if (request.Email != null) | ||||
|         { | ||||
|             var emailContact = await _db.AccountContacts.FirstOrDefaultAsync(c => c.AccountId == account.Id && c.Type == AccountContactType.Email); | ||||
|             if (emailContact != null) | ||||
|             { | ||||
|                 emailContact.Content = request.Email; | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 account.Contacts.Add(new AccountContact { Type = AccountContactType.Email, Content = request.Email }); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (request.DisplayName != null) | ||||
|         { | ||||
|             account.Nick = request.DisplayName; | ||||
|         } | ||||
|  | ||||
|         await _db.SaveChangesAsync(); | ||||
|  | ||||
|         return ToAccountResponse(account); | ||||
|     } | ||||
|  | ||||
|     private async Task<Shared.Models.Account> GetAccountFromContext(ServerCallContext context) | ||||
|     { | ||||
|         var authorizationHeader = context.RequestHeaders.FirstOrDefault(h => h.Key == "authorization"); | ||||
|         if (authorizationHeader == null) | ||||
|         { | ||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Missing authorization header.")); | ||||
|         } | ||||
|  | ||||
|         var token = authorizationHeader.Value.Replace("Bearer ", ""); | ||||
|         if (!_auth.ValidateToken(token, out var sessionId)) | ||||
|         { | ||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Invalid token.")); | ||||
|         } | ||||
|  | ||||
|         var session = await _db.AuthSessions.Include(s => s.Account).ThenInclude(a => a.Contacts).FirstOrDefaultAsync(s => s.Id == sessionId); | ||||
|         if (session == null) | ||||
|         { | ||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Session not found.")); | ||||
|         } | ||||
|  | ||||
|         return session.Account; | ||||
|     } | ||||
|  | ||||
|     private AccountResponse ToAccountResponse(Shared.Models.Account account) | ||||
|     { | ||||
|         var emailContact = account.Contacts.FirstOrDefault(c => c.Type == AccountContactType.Email); | ||||
|         return new AccountResponse | ||||
|         { | ||||
|             Id = account.Id.ToString(), | ||||
|             Username = account.Name, | ||||
|             Email = emailContact?.Content ?? "", | ||||
|             DisplayName = account.Nick | ||||
|         }; | ||||
|     } | ||||
| } | ||||
| @@ -1,658 +0,0 @@ | ||||
| using System.Globalization; | ||||
| using DysonNetwork.Shared.Models; | ||||
| 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(Shared.Models.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(Shared.Models.Account account) | ||||
|     { | ||||
|         await cache.RemoveGroupAsync($"{AccountCachePrefix}{account.Id}"); | ||||
|     } | ||||
|  | ||||
|     public async Task<Shared.Models.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<Shared.Models.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<Shared.Models.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 Shared.Models.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<Shared.Models.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(Shared.Models.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(Shared.Models.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(Shared.Models.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(Shared.Models.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(Shared.Models.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(Shared.Models.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(Shared.Models.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(Shared.Models.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(Shared.Models.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(Shared.Models.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(Shared.Models.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(Shared.Models.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(Shared.Models.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(Shared.Models.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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
| @@ -1,47 +0,0 @@ | ||||
| using DysonNetwork.Shared.Models; | ||||
| 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, | ||||
|         Shared.Models.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 Shared.Models.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 Session currentSession) | ||||
|             log.SessionId = currentSession.Id; | ||||
|  | ||||
|         fbs.Enqueue(log); | ||||
|     } | ||||
| } | ||||
| @@ -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 Shared.Models.Account? Account { get; set; } | ||||
| } | ||||
| @@ -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(); | ||||
|     } | ||||
| } | ||||
| @@ -1,253 +0,0 @@ | ||||
| using System.Globalization; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text.Json; | ||||
| using DysonNetwork.Shared.Models; | ||||
| 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( | ||||
|         Shared.Models.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); | ||||
|     } | ||||
| } | ||||
| @@ -1,167 +0,0 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using DysonNetwork.Shared.Models; | ||||
| 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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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(); | ||||
|     } | ||||
| } | ||||
| @@ -1,309 +0,0 @@ | ||||
| using System.Text; | ||||
| using System.Text.Json; | ||||
| using DysonNetwork.Shared.Models; | ||||
| 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( | ||||
|         Shared.Models.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( | ||||
|         Shared.Models.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<Shared.Models.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(); | ||||
|     } | ||||
| } | ||||
| @@ -1,254 +0,0 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using DysonNetwork.Shared.Models; | ||||
| 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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,208 +0,0 @@ | ||||
| using DysonNetwork.Shared.Models; | ||||
| 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(Shared.Models.Account sender, Shared.Models.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(Shared.Models.Account sender, Shared.Models.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(Shared.Models.Account sender, Shared.Models.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(Shared.Models.Account sender, Shared.Models.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(Shared.Models.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(Shared.Models.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}"); | ||||
|     } | ||||
| } | ||||
| @@ -13,7 +13,8 @@ public class ActivityService( | ||||
|     PublisherService pub, | ||||
|     RelationshipService rels, | ||||
|     PostService ps, | ||||
|     DiscoveryService ds) | ||||
|     DiscoveryService ds | ||||
| ) | ||||
| { | ||||
|     private static double CalculateHotRank(Post.Post post, Instant now) | ||||
|     { | ||||
|   | ||||
| @@ -1,22 +1,12 @@ | ||||
| using System.Linq.Expressions; | ||||
| using System.Reflection; | ||||
| using DysonNetwork.Shared.Models; | ||||
| using DysonNetwork.Sphere.Account; | ||||
| using DysonNetwork.Sphere.Auth; | ||||
| using DysonNetwork.Sphere.Chat; | ||||
| using DysonNetwork.Sphere.Developer; | ||||
| 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; | ||||
| @@ -33,28 +23,6 @@ public class AppDatabase( | ||||
|     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<Shared.Models.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; } | ||||
|  | ||||
| @@ -104,39 +72,7 @@ public class AppDatabase( | ||||
|                 .UseNetTopologySuite() | ||||
|                 .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); | ||||
|     } | ||||
|  | ||||
| @@ -144,25 +80,6 @@ public class AppDatabase( | ||||
|     { | ||||
|         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>() | ||||
| @@ -333,23 +250,9 @@ public class AppDatabaseRecyclingJob(AppDatabase db, ILogger<AppDatabaseRecyclin | ||||
| { | ||||
|     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 now = SystemClock.Instance.GetCurrentInstant(); | ||||
|         var threshold = now - Duration.FromDays(7); | ||||
|  | ||||
|         var entityTypes = db.Model.GetEntityTypes() | ||||
|   | ||||
| @@ -1,280 +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.Shared.Models; | ||||
| 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; | ||||
|     } | ||||
| } | ||||
| @@ -1,270 +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.Shared.Models; | ||||
| 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(); | ||||
|     } | ||||
| } | ||||
| @@ -1,94 +0,0 @@ | ||||
| using DysonNetwork.Sphere.Auth.Proto; | ||||
| using Grpc.Core; | ||||
| using Google.Protobuf.WellKnownTypes; | ||||
| using DysonNetwork.Sphere.Account; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime; | ||||
| using System.Text.Json; | ||||
| using DysonNetwork.Shared.Models; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Auth; | ||||
|  | ||||
| public class AuthGrpcService(AppDatabase db, AccountService accounts, AuthService auth) | ||||
|     : DysonNetwork.Sphere.Auth.Proto.AuthService.AuthServiceBase | ||||
| { | ||||
|     public override async Task<LoginResponse> Login(LoginRequest request, ServerCallContext context) | ||||
|     { | ||||
|         var account = await accounts.LookupAccount(request.Username); | ||||
|         if (account == null) | ||||
|         { | ||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found.")); | ||||
|         } | ||||
|  | ||||
|         var factor = await db.AccountAuthFactors.FirstOrDefaultAsync(f => f.AccountId == account.Id && f.Type == AccountAuthFactorType.Password); | ||||
|         if (factor == null || !factor.VerifyPassword(request.Password)) | ||||
|         { | ||||
|             throw new RpcException(new Grpc.Core.Status(StatusCode.Unauthenticated, "Invalid credentials.")); | ||||
|         } | ||||
|  | ||||
|         var session = new Session | ||||
|         { | ||||
|             LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow), | ||||
|             ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)), | ||||
|             Account = account, | ||||
|             Challenge = new Challenge() // Create a dummy challenge | ||||
|         }; | ||||
|  | ||||
|         db.AuthSessions.Add(session); | ||||
|         await db.SaveChangesAsync(); | ||||
|  | ||||
|         var token = auth.CreateToken(session); | ||||
|  | ||||
|         return new LoginResponse | ||||
|         { | ||||
|             AccessToken = token, | ||||
|             ExpiresIn = (long)(session.ExpiredAt.Value - session.LastGrantedAt.Value).TotalSeconds | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     public override async Task<IntrospectionResponse> IntrospectToken(IntrospectTokenRequest request, ServerCallContext context) | ||||
|     { | ||||
|         if (auth.ValidateToken(request.Token, out var sessionId)) | ||||
|         { | ||||
|             var session = await db.AuthSessions | ||||
|                 .Include(s => s.Account) | ||||
|                 .Include(s => s.Challenge) | ||||
|                 .FirstOrDefaultAsync(s => s.Id == sessionId); | ||||
|  | ||||
|             if (session != null) | ||||
|             { | ||||
|                 return new IntrospectionResponse | ||||
|                 { | ||||
|                     Active = true, | ||||
|                     Claims = JsonSerializer.Serialize(new { sub = session.AccountId }), | ||||
|                     ClientId = session.AppId?.ToString() ?? "", | ||||
|                     Username = session.Account.Name, | ||||
|                     Scope = string.Join(" ", session.Challenge.Scopes), | ||||
|                     Iat = Timestamp.FromDateTime(session.CreatedAt.ToDateTimeUtc()), | ||||
|                     Exp = Timestamp.FromDateTime(session.ExpiredAt?.ToDateTimeUtc() ?? DateTime.MaxValue) | ||||
|                 }; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return new IntrospectionResponse { Active = false }; | ||||
|     } | ||||
|  | ||||
|     public override async Task<Empty> Logout(Empty request, ServerCallContext context) | ||||
|     { | ||||
|         var authorizationHeader = context.RequestHeaders.FirstOrDefault(h => h.Key == "authorization"); | ||||
|         if (authorizationHeader != null) | ||||
|         { | ||||
|             var token = authorizationHeader.Value.Replace("Bearer ", ""); | ||||
|             if (auth.ValidateToken(token, out var sessionId)) | ||||
|             { | ||||
|                 var session = await db.AuthSessions.FindAsync(sessionId); | ||||
|                 if (session != null) | ||||
|                 { | ||||
|                     db.AuthSessions.Remove(session); | ||||
|                     await db.SaveChangesAsync(); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         return new Empty(); | ||||
|     } | ||||
| } | ||||
| @@ -1,305 +0,0 @@ | ||||
| using System.Security.Cryptography; | ||||
| using System.Text.Json; | ||||
| using DysonNetwork.Shared.Models; | ||||
| 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, Shared.Models.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(); | ||||
|  | ||||
|         // We’ll 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, you’d 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(Shared.Models.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); | ||||
|     } | ||||
| } | ||||
| @@ -1,6 +0,0 @@ | ||||
| namespace DysonNetwork.Sphere.Auth; | ||||
|  | ||||
| public class CaptchaVerificationResponse | ||||
| { | ||||
|     public bool Success { get; set; } | ||||
| } | ||||
| @@ -1,95 +0,0 @@ | ||||
| using System.Security.Cryptography; | ||||
| using DysonNetwork.Shared.Models; | ||||
|  | ||||
| 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); | ||||
|     } | ||||
| } | ||||
| @@ -1,243 +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.Shared.Models; | ||||
| 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 Shared.Models.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; } | ||||
| } | ||||
| @@ -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; } | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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; } | ||||
| } | ||||
| @@ -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; } | ||||
| } | ||||
| @@ -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; } | ||||
| } | ||||
| @@ -1,396 +0,0 @@ | ||||
| using System.IdentityModel.Tokens.Jwt; | ||||
| using System.Security.Claims; | ||||
| using System.Security.Cryptography; | ||||
| using System.Text; | ||||
| using DysonNetwork.Shared.Models; | ||||
| 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; | ||||
|     } | ||||
| } | ||||
| @@ -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; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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; } | ||||
| } | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
| @@ -1,410 +0,0 @@ | ||||
| using DysonNetwork.Shared.Models; | ||||
| 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 Shared.Models.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 Shared.Models.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 Shared.Models.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 Shared.Models.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; | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|         }; | ||||
|     } | ||||
| } | ||||
| @@ -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; } | ||||
|     } | ||||
| } | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|         }; | ||||
|     } | ||||
| } | ||||
| @@ -1,195 +0,0 @@ | ||||
| using DysonNetwork.Shared.Models; | ||||
| 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 Shared.Models.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<Shared.Models.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; | ||||
|     } | ||||
| } | ||||
| @@ -1,296 +0,0 @@ | ||||
| using System.IdentityModel.Tokens.Jwt; | ||||
| using System.Net.Http.Json; | ||||
| using System.Text.Json.Serialization; | ||||
| using DysonNetwork.Shared.Models; | ||||
| 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, | ||||
|         Shared.Models.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; } | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -1,3 +1,4 @@ | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using DysonNetwork.Shared.Models; | ||||
| using DysonNetwork.Sphere.Storage; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
|   | ||||
| @@ -4,6 +4,7 @@ using Livekit.Server.Sdk.Dotnet; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime; | ||||
| using System.Text.Json; | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using DysonNetwork.Shared.Models; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Chat.Realtime; | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| using System.Globalization; | ||||
| using AngleSharp; | ||||
| using AngleSharp.Dom; | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using DysonNetwork.Sphere.Storage; | ||||
| using HtmlAgilityPack; | ||||
|  | ||||
|   | ||||
| @@ -83,6 +83,7 @@ | ||||
|     </ItemGroup> | ||||
|  | ||||
|     <ItemGroup> | ||||
|         <Folder Include="Auth\" /> | ||||
|         <Folder Include="Discovery\" /> | ||||
|     </ItemGroup> | ||||
|  | ||||
| @@ -164,7 +165,7 @@ | ||||
|     </ItemGroup> | ||||
|  | ||||
|     <ItemGroup> | ||||
|         <PackageReference Include="Grpc.AspNetCore" Version="2.65.0" /> | ||||
|         <PackageReference Include="Grpc.AspNetCore" Version="2.71.0" /> | ||||
|     </ItemGroup> | ||||
|  | ||||
|     <ItemGroup> | ||||
|   | ||||
| @@ -1,51 +0,0 @@ | ||||
| namespace DysonNetwork.Sphere.Permission; | ||||
|  | ||||
| using System; | ||||
|  | ||||
| [AttributeUsage(AttributeTargets.Method, Inherited = true)] | ||||
| public class RequiredPermissionAttribute(string area, string key) : Attribute | ||||
| { | ||||
|     public string Area { get; set; } = area; | ||||
|     public string Key { get; } = key; | ||||
| } | ||||
|  | ||||
| public class PermissionMiddleware(RequestDelegate next) | ||||
| { | ||||
|     public async Task InvokeAsync(HttpContext httpContext, PermissionService pm) | ||||
|     { | ||||
|         var endpoint = httpContext.GetEndpoint(); | ||||
|  | ||||
|         var attr = endpoint?.Metadata | ||||
|             .OfType<RequiredPermissionAttribute>() | ||||
|             .FirstOrDefault(); | ||||
|  | ||||
|         if (attr != null) | ||||
|         { | ||||
|             if (httpContext.Items["CurrentUser"] is not Shared.Models.Account currentUser) | ||||
|             { | ||||
|                 httpContext.Response.StatusCode = StatusCodes.Status403Forbidden; | ||||
|                 await httpContext.Response.WriteAsync("Unauthorized"); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             if (currentUser.IsSuperuser) | ||||
|             { | ||||
|                 // Bypass the permission check for performance | ||||
|                 await next(httpContext); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             var actor = $"user:{currentUser.Id}"; | ||||
|             var permNode = await pm.GetPermissionAsync<bool>(actor, attr.Area, attr.Key); | ||||
|  | ||||
|             if (!permNode) | ||||
|             { | ||||
|                 httpContext.Response.StatusCode = StatusCodes.Status403Forbidden; | ||||
|                 await httpContext.Response.WriteAsync($"Permission {attr.Area}/{attr.Key} = {true} was required."); | ||||
|                 return; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         await next(httpContext); | ||||
|     }  | ||||
| } | ||||
| @@ -1,198 +0,0 @@ | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime; | ||||
| using System.Text.Json; | ||||
| using DysonNetwork.Shared.Models; | ||||
| using DysonNetwork.Sphere.Storage; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Permission; | ||||
|  | ||||
| public class PermissionService( | ||||
|     AppDatabase db, | ||||
|     ICacheService cache | ||||
| ) | ||||
| { | ||||
|     private static readonly TimeSpan CacheExpiration = TimeSpan.FromMinutes(1); | ||||
|  | ||||
|     private const string PermCacheKeyPrefix = "perm:"; | ||||
|     private const string PermGroupCacheKeyPrefix = "perm-cg:"; | ||||
|     private const string PermissionGroupPrefix = "perm-g:"; | ||||
|  | ||||
|     private static string _GetPermissionCacheKey(string actor, string area, string key) => | ||||
|         PermCacheKeyPrefix + actor + ":" + area + ":" + key; | ||||
|  | ||||
|     private static string _GetGroupsCacheKey(string actor) => | ||||
|         PermGroupCacheKeyPrefix + actor; | ||||
|  | ||||
|     private static string _GetPermissionGroupKey(string actor) => | ||||
|         PermissionGroupPrefix + actor; | ||||
|  | ||||
|     public async Task<bool> HasPermissionAsync(string actor, string area, string key) | ||||
|     { | ||||
|         var value = await GetPermissionAsync<bool>(actor, area, key); | ||||
|         return value; | ||||
|     } | ||||
|  | ||||
|     public async Task<T?> GetPermissionAsync<T>(string actor, string area, string key) | ||||
|     { | ||||
|         var cacheKey = _GetPermissionCacheKey(actor, area, key); | ||||
|  | ||||
|         var (hit, cachedValue) = await cache.GetAsyncWithStatus<T>(cacheKey); | ||||
|         if (hit) | ||||
|             return cachedValue; | ||||
|  | ||||
|         var now = SystemClock.Instance.GetCurrentInstant(); | ||||
|         var groupsKey = _GetGroupsCacheKey(actor); | ||||
|  | ||||
|         var groupsId = await cache.GetAsync<List<Guid>>(groupsKey); | ||||
|         if (groupsId == null) | ||||
|         { | ||||
|             groupsId = await db.PermissionGroupMembers | ||||
|                 .Where(n => n.Actor == actor) | ||||
|                 .Where(n => n.ExpiredAt == null || n.ExpiredAt > now) | ||||
|                 .Where(n => n.AffectedAt == null || n.AffectedAt <= now) | ||||
|                 .Select(e => e.GroupId) | ||||
|                 .ToListAsync(); | ||||
|  | ||||
|             await cache.SetWithGroupsAsync(groupsKey, groupsId, | ||||
|                 [_GetPermissionGroupKey(actor)], | ||||
|                 CacheExpiration); | ||||
|         } | ||||
|  | ||||
|         var permission = await db.PermissionNodes | ||||
|             .Where(n => (n.GroupId == null && n.Actor == actor) || | ||||
|                         (n.GroupId != null && groupsId.Contains(n.GroupId.Value))) | ||||
|             .Where(n => n.Key == key && n.Area == area) | ||||
|             .Where(n => n.ExpiredAt == null || n.ExpiredAt > now) | ||||
|             .Where(n => n.AffectedAt == null || n.AffectedAt <= now) | ||||
|             .FirstOrDefaultAsync(); | ||||
|  | ||||
|         var result = permission is not null ? _DeserializePermissionValue<T>(permission.Value) : default; | ||||
|  | ||||
|         await cache.SetWithGroupsAsync(cacheKey, result, | ||||
|             [_GetPermissionGroupKey(actor)], | ||||
|             CacheExpiration); | ||||
|  | ||||
|         return result; | ||||
|     } | ||||
|  | ||||
|     public async Task<PermissionNode> AddPermissionNode<T>( | ||||
|         string actor, | ||||
|         string area, | ||||
|         string key, | ||||
|         T value, | ||||
|         Instant? expiredAt = null, | ||||
|         Instant? affectedAt = null | ||||
|     ) | ||||
|     { | ||||
|         if (value is null) throw new ArgumentNullException(nameof(value)); | ||||
|  | ||||
|         var node = new PermissionNode | ||||
|         { | ||||
|             Actor = actor, | ||||
|             Key = key, | ||||
|             Area = area, | ||||
|             Value = _SerializePermissionValue(value), | ||||
|             ExpiredAt = expiredAt, | ||||
|             AffectedAt = affectedAt | ||||
|         }; | ||||
|  | ||||
|         db.PermissionNodes.Add(node); | ||||
|         await db.SaveChangesAsync(); | ||||
|  | ||||
|         // Invalidate related caches | ||||
|         await InvalidatePermissionCacheAsync(actor, area, key); | ||||
|  | ||||
|         return node; | ||||
|     } | ||||
|  | ||||
|     public async Task<PermissionNode> AddPermissionNodeToGroup<T>( | ||||
|         PermissionGroup group, | ||||
|         string actor, | ||||
|         string area, | ||||
|         string key, | ||||
|         T value, | ||||
|         Instant? expiredAt = null, | ||||
|         Instant? affectedAt = null | ||||
|     ) | ||||
|     { | ||||
|         if (value is null) throw new ArgumentNullException(nameof(value)); | ||||
|  | ||||
|         var node = new PermissionNode | ||||
|         { | ||||
|             Actor = actor, | ||||
|             Key = key, | ||||
|             Area = area, | ||||
|             Value = _SerializePermissionValue(value), | ||||
|             ExpiredAt = expiredAt, | ||||
|             AffectedAt = affectedAt, | ||||
|             Group = group, | ||||
|             GroupId = group.Id | ||||
|         }; | ||||
|  | ||||
|         db.PermissionNodes.Add(node); | ||||
|         await db.SaveChangesAsync(); | ||||
|  | ||||
|         // Invalidate related caches | ||||
|         await InvalidatePermissionCacheAsync(actor, area, key); | ||||
|         await cache.RemoveAsync(_GetGroupsCacheKey(actor)); | ||||
|         await cache.RemoveGroupAsync(_GetPermissionGroupKey(actor)); | ||||
|  | ||||
|         return node; | ||||
|     } | ||||
|  | ||||
|     public async Task RemovePermissionNode(string actor, string area, string key) | ||||
|     { | ||||
|         var node = await db.PermissionNodes | ||||
|             .Where(n => n.Actor == actor && n.Area == area && n.Key == key) | ||||
|             .FirstOrDefaultAsync(); | ||||
|         if (node is not null) db.PermissionNodes.Remove(node); | ||||
|         await db.SaveChangesAsync(); | ||||
|  | ||||
|         // Invalidate cache | ||||
|         await InvalidatePermissionCacheAsync(actor, area, key); | ||||
|     } | ||||
|  | ||||
|     public async Task RemovePermissionNodeFromGroup<T>(PermissionGroup group, string actor, string area, string key) | ||||
|     { | ||||
|         var node = await db.PermissionNodes | ||||
|             .Where(n => n.GroupId == group.Id) | ||||
|             .Where(n => n.Actor == actor && n.Area == area && n.Key == key) | ||||
|             .FirstOrDefaultAsync(); | ||||
|         if (node is null) return; | ||||
|         db.PermissionNodes.Remove(node); | ||||
|         await db.SaveChangesAsync(); | ||||
|  | ||||
|         // Invalidate caches | ||||
|         await InvalidatePermissionCacheAsync(actor, area, key); | ||||
|         await cache.RemoveAsync(_GetGroupsCacheKey(actor)); | ||||
|         await cache.RemoveGroupAsync(_GetPermissionGroupKey(actor)); | ||||
|     } | ||||
|  | ||||
|     private async Task InvalidatePermissionCacheAsync(string actor, string area, string key) | ||||
|     { | ||||
|         var cacheKey = _GetPermissionCacheKey(actor, area, key); | ||||
|         await cache.RemoveAsync(cacheKey); | ||||
|     } | ||||
|  | ||||
|     private static T? _DeserializePermissionValue<T>(JsonDocument json) | ||||
|     { | ||||
|         return JsonSerializer.Deserialize<T>(json.RootElement.GetRawText()); | ||||
|     } | ||||
|  | ||||
|     private static JsonDocument _SerializePermissionValue<T>(T obj) | ||||
|     { | ||||
|         var str = JsonSerializer.Serialize(obj); | ||||
|         return JsonDocument.Parse(str); | ||||
|     } | ||||
|  | ||||
|     public static PermissionNode NewPermissionNode<T>(string actor, string area, string key, T value) | ||||
|     { | ||||
|         return new PermissionNode | ||||
|         { | ||||
|             Actor = actor, | ||||
|             Area = area, | ||||
|             Key = key, | ||||
|             Value = _SerializePermissionValue(value), | ||||
|         }; | ||||
|     } | ||||
| } | ||||
| @@ -1,4 +1,5 @@ | ||||
| using System.Text.RegularExpressions; | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using DysonNetwork.Sphere.Account; | ||||
| using DysonNetwork.Sphere.Connection.WebReader; | ||||
| using DysonNetwork.Sphere.Localization; | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using DysonNetwork.Shared.Models; | ||||
| using DysonNetwork.Sphere.Post; | ||||
| using DysonNetwork.Sphere.Storage; | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using DysonNetwork.Shared.Models; | ||||
| using DysonNetwork.Sphere.Account; | ||||
| using DysonNetwork.Sphere.Localization; | ||||
|   | ||||
| @@ -24,6 +24,7 @@ using NodaTime.Serialization.SystemTextJson; | ||||
| using StackExchange.Redis; | ||||
| using System.Text.Json; | ||||
| using System.Threading.RateLimiting; | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Options; | ||||
| using DysonNetwork.Sphere.Auth.OidcProvider.Services; | ||||
| using DysonNetwork.Sphere.Connection.WebReader; | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using DysonNetwork.Shared.Models; | ||||
| using DysonNetwork.Sphere.Storage; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
|   | ||||
| @@ -1,396 +0,0 @@ | ||||
| using Newtonsoft.Json; | ||||
| using Newtonsoft.Json.Serialization; | ||||
| using NodaTime; | ||||
| using NodaTime.Serialization.JsonNet; | ||||
| using StackExchange.Redis; | ||||
|  | ||||
| namespace DysonNetwork.Sphere.Storage; | ||||
|  | ||||
| /// <summary> | ||||
| /// Represents a distributed lock that can be used to synchronize access across multiple processes | ||||
| /// </summary> | ||||
| public interface IDistributedLock : IAsyncDisposable | ||||
| { | ||||
|     /// <summary> | ||||
|     /// The resource identifier this lock is protecting | ||||
|     /// </summary> | ||||
|     string Resource { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Unique identifier for this lock instance | ||||
|     /// </summary> | ||||
|     string LockId { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Extends the lock's expiration time | ||||
|     /// </summary> | ||||
|     Task<bool> ExtendAsync(TimeSpan timeSpan); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Releases the lock immediately | ||||
|     /// </summary> | ||||
|     Task ReleaseAsync(); | ||||
| } | ||||
|  | ||||
| public interface ICacheService | ||||
| { | ||||
|     /// <summary> | ||||
|     /// Sets a value in the cache with an optional expiration time | ||||
|     /// </summary> | ||||
|     Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Gets a value from the cache | ||||
|     /// </summary> | ||||
|     Task<T?> GetAsync<T>(string key); | ||||
|      | ||||
|     /// <summary> | ||||
|     /// Get a value from the cache with the found status | ||||
|     /// </summary> | ||||
|     Task<(bool found, T? value)> GetAsyncWithStatus<T>(string key); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Removes a specific key from the cache | ||||
|     /// </summary> | ||||
|     Task<bool> RemoveAsync(string key); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Adds a key to a group for group-based operations | ||||
|     /// </summary> | ||||
|     Task AddToGroupAsync(string key, string group); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Removes all keys associated with a specific group | ||||
|     /// </summary> | ||||
|     Task RemoveGroupAsync(string group); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Gets all keys belonging to a specific group | ||||
|     /// </summary> | ||||
|     Task<IEnumerable<string>> GetGroupKeysAsync(string group); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Helper method to set a value in cache and associate it with multiple groups in one operation | ||||
|     /// </summary> | ||||
|     /// <typeparam name="T">The type of value being cached</typeparam> | ||||
|     /// <param name="key">Cache key</param> | ||||
|     /// <param name="value">The value to cache</param> | ||||
|     /// <param name="groups">Optional collection of group names to associate the key with</param> | ||||
|     /// <param name="expiry">Optional expiration time for the cached item</param> | ||||
|     /// <returns>True if the set operation was successful</returns> | ||||
|     Task<bool> SetWithGroupsAsync<T>(string key, T value, IEnumerable<string>? groups = null, TimeSpan? expiry = null); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Acquires a distributed lock on the specified resource | ||||
|     /// </summary> | ||||
|     /// <param name="resource">The resource identifier to lock</param> | ||||
|     /// <param name="expiry">How long the lock should be held before automatically expiring</param> | ||||
|     /// <param name="waitTime">How long to wait for the lock before giving up</param> | ||||
|     /// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param> | ||||
|     /// <returns>A distributed lock instance if acquired, null otherwise</returns> | ||||
|     Task<IDistributedLock?> AcquireLockAsync(string resource, TimeSpan expiry, TimeSpan? waitTime = null, | ||||
|         TimeSpan? retryInterval = null); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Executes an action with a distributed lock, ensuring the lock is properly released afterwards | ||||
|     /// </summary> | ||||
|     /// <param name="resource">The resource identifier to lock</param> | ||||
|     /// <param name="action">The action to execute while holding the lock</param> | ||||
|     /// <param name="expiry">How long the lock should be held before automatically expiring</param> | ||||
|     /// <param name="waitTime">How long to wait for the lock before giving up</param> | ||||
|     /// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param> | ||||
|     /// <returns>True if the lock was acquired and the action was executed, false otherwise</returns> | ||||
|     Task<bool> ExecuteWithLockAsync(string resource, Func<Task> action, TimeSpan expiry, TimeSpan? waitTime = null, | ||||
|         TimeSpan? retryInterval = null); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Executes a function with a distributed lock, ensuring the lock is properly released afterwards | ||||
|     /// </summary> | ||||
|     /// <typeparam name="T">The return type of the function</typeparam> | ||||
|     /// <param name="resource">The resource identifier to lock</param> | ||||
|     /// <param name="func">The function to execute while holding the lock</param> | ||||
|     /// <param name="expiry">How long the lock should be held before automatically expiring</param> | ||||
|     /// <param name="waitTime">How long to wait for the lock before giving up</param> | ||||
|     /// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param> | ||||
|     /// <returns>The result of the function if the lock was acquired, default(T) otherwise</returns> | ||||
|     Task<(bool Acquired, T? Result)> ExecuteWithLockAsync<T>(string resource, Func<Task<T>> func, TimeSpan expiry, | ||||
|         TimeSpan? waitTime = null, TimeSpan? retryInterval = null); | ||||
| } | ||||
|  | ||||
| public class RedisDistributedLock : IDistributedLock | ||||
| { | ||||
|     private readonly IDatabase _database; | ||||
|     private bool _disposed; | ||||
|  | ||||
|     public string Resource { get; } | ||||
|     public string LockId { get; } | ||||
|  | ||||
|     internal RedisDistributedLock(IDatabase database, string resource, string lockId) | ||||
|     { | ||||
|         _database = database; | ||||
|         Resource = resource; | ||||
|         LockId = lockId; | ||||
|     } | ||||
|  | ||||
|     public async Task<bool> ExtendAsync(TimeSpan timeSpan) | ||||
|     { | ||||
|         if (_disposed) | ||||
|             throw new ObjectDisposedException(nameof(RedisDistributedLock)); | ||||
|  | ||||
|         var script = @" | ||||
|             if redis.call('get', KEYS[1]) == ARGV[1] then | ||||
|                 return redis.call('pexpire', KEYS[1], ARGV[2]) | ||||
|             else | ||||
|                 return 0 | ||||
|             end | ||||
|         "; | ||||
|  | ||||
|         var result = await _database.ScriptEvaluateAsync( | ||||
|             script, | ||||
|             [$"{CacheServiceRedis.LockKeyPrefix}{Resource}"], | ||||
|             [LockId, (long)timeSpan.TotalMilliseconds] | ||||
|         ); | ||||
|  | ||||
|         return (long)result! == 1; | ||||
|     } | ||||
|  | ||||
|     public async Task ReleaseAsync() | ||||
|     { | ||||
|         if (_disposed) | ||||
|             return; | ||||
|  | ||||
|         var script = @" | ||||
|             if redis.call('get', KEYS[1]) == ARGV[1] then | ||||
|                 return redis.call('del', KEYS[1]) | ||||
|             else | ||||
|                 return 0 | ||||
|             end | ||||
|         "; | ||||
|  | ||||
|         await _database.ScriptEvaluateAsync( | ||||
|             script, | ||||
|             [$"{CacheServiceRedis.LockKeyPrefix}{Resource}"], | ||||
|             [LockId] | ||||
|         ); | ||||
|  | ||||
|         _disposed = true; | ||||
|     } | ||||
|  | ||||
|     public async ValueTask DisposeAsync() | ||||
|     { | ||||
|         await ReleaseAsync(); | ||||
|         GC.SuppressFinalize(this); | ||||
|     } | ||||
| } | ||||
|  | ||||
| public class CacheServiceRedis : ICacheService | ||||
| { | ||||
|     private readonly IDatabase _database; | ||||
|     private readonly JsonSerializerSettings _serializerSettings; | ||||
|  | ||||
|     // Global prefix for all cache keys | ||||
|     public const string GlobalKeyPrefix = "dyson:"; | ||||
|  | ||||
|     // Using prefixes for different types of keys | ||||
|     public const string GroupKeyPrefix = GlobalKeyPrefix + "cg:"; | ||||
|     public const string LockKeyPrefix = GlobalKeyPrefix + "lock:"; | ||||
|  | ||||
|     public CacheServiceRedis(IConnectionMultiplexer redis) | ||||
|     { | ||||
|         var rds = redis ?? throw new ArgumentNullException(nameof(redis)); | ||||
|         _database = rds.GetDatabase(); | ||||
|          | ||||
|         // Configure Newtonsoft.Json with proper NodaTime serialization | ||||
|         _serializerSettings = new JsonSerializerSettings | ||||
|         { | ||||
|             ContractResolver = new CamelCasePropertyNamesContractResolver(), | ||||
|             PreserveReferencesHandling = PreserveReferencesHandling.Objects, | ||||
|             NullValueHandling = NullValueHandling.Include, | ||||
|             DateParseHandling = DateParseHandling.None | ||||
|         }; | ||||
|          | ||||
|         // Configure NodaTime serializers | ||||
|         _serializerSettings.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb); | ||||
|     } | ||||
|  | ||||
|     public async Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null) | ||||
|     { | ||||
|         key = $"{GlobalKeyPrefix}{key}"; | ||||
|         if (string.IsNullOrEmpty(key)) | ||||
|             throw new ArgumentException("Key cannot be null or empty", nameof(key)); | ||||
|  | ||||
|         var serializedValue = JsonConvert.SerializeObject(value, _serializerSettings); | ||||
|         return await _database.StringSetAsync(key, serializedValue, expiry); | ||||
|     } | ||||
|  | ||||
|     public async Task<T?> GetAsync<T>(string key) | ||||
|     { | ||||
|         key = $"{GlobalKeyPrefix}{key}"; | ||||
|         if (string.IsNullOrEmpty(key)) | ||||
|             throw new ArgumentException("Key cannot be null or empty", nameof(key)); | ||||
|  | ||||
|         var value = await _database.StringGetAsync(key); | ||||
|  | ||||
|         if (value.IsNullOrEmpty) | ||||
|             return default; | ||||
|  | ||||
|         // For NodaTime serialization, use the configured serializer settings | ||||
|         return JsonConvert.DeserializeObject<T>(value!, _serializerSettings); | ||||
|     } | ||||
|  | ||||
|     public async Task<(bool found, T? value)> GetAsyncWithStatus<T>(string key) | ||||
|     { | ||||
|         key = $"{GlobalKeyPrefix}{key}"; | ||||
|         if (string.IsNullOrEmpty(key)) | ||||
|             throw new ArgumentException("Key cannot be null or empty", nameof(key)); | ||||
|  | ||||
|         var value = await _database.StringGetAsync(key); | ||||
|  | ||||
|         if (value.IsNullOrEmpty) | ||||
|             return (false, default); | ||||
|  | ||||
|         // For NodaTime serialization, use the configured serializer settings | ||||
|         return (true, JsonConvert.DeserializeObject<T>(value!, _serializerSettings)); | ||||
|     } | ||||
|  | ||||
|     public async Task<bool> RemoveAsync(string key) | ||||
|     { | ||||
|         key = $"{GlobalKeyPrefix}{key}"; | ||||
|         if (string.IsNullOrEmpty(key)) | ||||
|             throw new ArgumentException("Key cannot be null or empty", nameof(key)); | ||||
|  | ||||
|         // Before removing the key, find all groups it belongs to and remove it from them | ||||
|         var script = @" | ||||
|             local groups = redis.call('KEYS', ARGV[1]) | ||||
|             for _, group in ipairs(groups) do | ||||
|                 redis.call('SREM', group, ARGV[2]) | ||||
|             end | ||||
|             return redis.call('DEL', ARGV[2]) | ||||
|         "; | ||||
|  | ||||
|         var result = await _database.ScriptEvaluateAsync( | ||||
|             script, | ||||
|             values: [$"{GroupKeyPrefix}*", key] | ||||
|         ); | ||||
|  | ||||
|         return (long)result! > 0; | ||||
|     } | ||||
|  | ||||
|     public async Task AddToGroupAsync(string key, string group) | ||||
|     { | ||||
|         if (string.IsNullOrEmpty(key)) | ||||
|             throw new ArgumentException(@"Key cannot be null or empty.", nameof(key)); | ||||
|  | ||||
|         if (string.IsNullOrEmpty(group)) | ||||
|             throw new ArgumentException(@"Group cannot be null or empty.", nameof(group)); | ||||
|  | ||||
|         var groupKey = $"{GroupKeyPrefix}{group}"; | ||||
|         key = $"{GlobalKeyPrefix}{key}"; | ||||
|         await _database.SetAddAsync(groupKey, key); | ||||
|     } | ||||
|  | ||||
|     public async Task RemoveGroupAsync(string group) | ||||
|     { | ||||
|         if (string.IsNullOrEmpty(group)) | ||||
|             throw new ArgumentException(@"Group cannot be null or empty.", nameof(group)); | ||||
|  | ||||
|         var groupKey = $"{GroupKeyPrefix}{group}"; | ||||
|  | ||||
|         // Get all keys in the group | ||||
|         var keys = await _database.SetMembersAsync(groupKey); | ||||
|  | ||||
|         if (keys.Length > 0) | ||||
|         { | ||||
|             // Delete all the keys | ||||
|             var keysTasks = keys.Select(key => _database.KeyDeleteAsync(key.ToString())); | ||||
|             await Task.WhenAll(keysTasks); | ||||
|         } | ||||
|  | ||||
|         // Delete the group itself | ||||
|         await _database.KeyDeleteAsync(groupKey); | ||||
|     } | ||||
|  | ||||
|     public async Task<IEnumerable<string>> GetGroupKeysAsync(string group) | ||||
|     { | ||||
|         if (string.IsNullOrEmpty(group)) | ||||
|             throw new ArgumentException(@"Group cannot be null or empty.", nameof(group)); | ||||
|  | ||||
|         var groupKey = $"{GroupKeyPrefix}{group}"; | ||||
|         var members = await _database.SetMembersAsync(groupKey); | ||||
|  | ||||
|         return members.Select(m => m.ToString()); | ||||
|     } | ||||
|  | ||||
|     public async Task<bool> SetWithGroupsAsync<T>(string key, T value, IEnumerable<string>? groups = null, | ||||
|         TimeSpan? expiry = null) | ||||
|     { | ||||
|         // First, set the value in the cache | ||||
|         var setResult = await SetAsync(key, value, expiry); | ||||
|  | ||||
|         // If successful and there are groups to associate, add the key to each group | ||||
|         if (!setResult || groups == null) return setResult; | ||||
|         var groupsArray = groups.Where(g => !string.IsNullOrEmpty(g)).ToArray(); | ||||
|         if (groupsArray.Length <= 0) return setResult; | ||||
|         var tasks = groupsArray.Select(group => AddToGroupAsync(key, group)); | ||||
|         await Task.WhenAll(tasks); | ||||
|  | ||||
|         return setResult; | ||||
|     } | ||||
|  | ||||
|     public async Task<IDistributedLock?> AcquireLockAsync(string resource, TimeSpan expiry, TimeSpan? waitTime = null, | ||||
|         TimeSpan? retryInterval = null) | ||||
|     { | ||||
|         if (string.IsNullOrEmpty(resource)) | ||||
|             throw new ArgumentException("Resource cannot be null or empty", nameof(resource)); | ||||
|  | ||||
|         var lockKey = $"{LockKeyPrefix}{resource}"; | ||||
|         var lockId = Guid.NewGuid().ToString("N"); | ||||
|         var waitTimeSpan = waitTime ?? TimeSpan.Zero; | ||||
|         var retryIntervalSpan = retryInterval ?? TimeSpan.FromMilliseconds(100); | ||||
|  | ||||
|         var startTime = DateTime.UtcNow; | ||||
|         var acquired = false; | ||||
|  | ||||
|         // Try to acquire the lock, retry until waitTime is exceeded | ||||
|         while (!acquired && (DateTime.UtcNow - startTime) < waitTimeSpan) | ||||
|         { | ||||
|             acquired = await _database.StringSetAsync(lockKey, lockId, expiry, When.NotExists); | ||||
|  | ||||
|             if (!acquired) | ||||
|             { | ||||
|                 await Task.Delay(retryIntervalSpan); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (!acquired) | ||||
|         { | ||||
|             return null; // Could not acquire the lock within the wait time | ||||
|         } | ||||
|  | ||||
|         return new RedisDistributedLock(_database, resource, lockId); | ||||
|     } | ||||
|  | ||||
|     public async Task<bool> ExecuteWithLockAsync(string resource, Func<Task> action, TimeSpan expiry, | ||||
|         TimeSpan? waitTime = null, TimeSpan? retryInterval = null) | ||||
|     { | ||||
|         await using var lockObj = await AcquireLockAsync(resource, expiry, waitTime, retryInterval); | ||||
|  | ||||
|         if (lockObj == null) | ||||
|             return false; // Could not acquire the lock | ||||
|  | ||||
|         await action(); | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     public async Task<(bool Acquired, T? Result)> ExecuteWithLockAsync<T>(string resource, Func<Task<T>> func, | ||||
|         TimeSpan expiry, TimeSpan? waitTime = null, TimeSpan? retryInterval = null) | ||||
|     { | ||||
|         await using var lockObj = await AcquireLockAsync(resource, expiry, waitTime, retryInterval); | ||||
|  | ||||
|         if (lockObj == null) | ||||
|             return (false, default); // Could not acquire the lock | ||||
|  | ||||
|         var result = await func(); | ||||
|         return (true, result); | ||||
|     } | ||||
| } | ||||
| @@ -1,3 +1,4 @@ | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using DysonNetwork.Shared.Models; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime; | ||||
|   | ||||
| @@ -2,6 +2,7 @@ using System.Globalization; | ||||
| using FFMpegCore; | ||||
| using System.Security.Cryptography; | ||||
| using AngleSharp.Text; | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using DysonNetwork.Shared.Models; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Minio; | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using NodaTime; | ||||
| using Quartz; | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| using System.Text.Json; | ||||
| using DysonNetwork.Shared.Cache; | ||||
| using DysonNetwork.Shared.Models; | ||||
| using DysonNetwork.Sphere.Account; | ||||
| using DysonNetwork.Sphere.Localization; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user