diff --git a/DysonNetwork.Pass/Account/AbuseReport.cs b/DysonNetwork.Pass/Account/AbuseReport.cs new file mode 100644 index 0000000..ebb7a26 --- /dev/null +++ b/DysonNetwork.Pass/Account/AbuseReport.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using NodaTime; + +namespace DysonNetwork.Pass.Account; + +public enum AbuseReportType +{ + Copyright, + Harassment, + Impersonation, + OffensiveContent, + Spam, + PrivacyViolation, + IllegalContent, + Other +} + +public class AbuseReport : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + [MaxLength(4096)] public string ResourceIdentifier { get; set; } = null!; + public AbuseReportType Type { get; set; } + [MaxLength(8192)] public string Reason { get; set; } = null!; + + public Instant? ResolvedAt { get; set; } + [MaxLength(8192)] public string? Resolution { get; set; } + + public Guid AccountId { get; set; } + public Account Account { get; set; } = null!; +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/Account.cs b/DysonNetwork.Pass/Account/Account.cs new file mode 100644 index 0000000..6674ae2 --- /dev/null +++ b/DysonNetwork.Pass/Account/Account.cs @@ -0,0 +1,190 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; +using NodaTime; +using OtpNet; + +namespace DysonNetwork.Pass.Account; + +[Index(nameof(Name), IsUnique = true)] +public class Account : ModelBase +{ + public Guid Id { get; set; } + [MaxLength(256)] public string Name { get; set; } = string.Empty; + [MaxLength(256)] public string Nick { get; set; } = string.Empty; + [MaxLength(32)] public string Language { get; set; } = string.Empty; + public Instant? ActivatedAt { get; set; } + public bool IsSuperuser { get; set; } = false; + + public Profile Profile { get; set; } = null!; + public ICollection Contacts { get; set; } = new List(); + public ICollection Badges { get; set; } = new List(); + + [JsonIgnore] public ICollection AuthFactors { get; set; } = new List(); + [JsonIgnore] public ICollection Connections { get; set; } = new List(); + [JsonIgnore] public ICollection Sessions { get; set; } = new List(); + [JsonIgnore] public ICollection Challenges { get; set; } = new List(); + + [JsonIgnore] public ICollection OutgoingRelationships { get; set; } = new List(); + [JsonIgnore] public ICollection IncomingRelationships { get; set; } = new List(); +} + +public abstract class Leveling +{ + public static readonly List ExperiencePerLevel = + [ + 0, // Level 0 + 100, // Level 1 + 250, // Level 2 + 500, // Level 3 + 1000, // Level 4 + 2000, // Level 5 + 4000, // Level 6 + 8000, // Level 7 + 16000, // Level 8 + 32000, // Level 9 + 64000, // Level 10 + 128000, // Level 11 + 256000, // Level 12 + 512000, // Level 13 + 1024000 // Level 14 + ]; +} + +public class Profile : ModelBase +{ + public Guid Id { get; set; } + [MaxLength(256)] public string? FirstName { get; set; } + [MaxLength(256)] public string? MiddleName { get; set; } + [MaxLength(256)] public string? LastName { get; set; } + [MaxLength(4096)] public string? Bio { get; set; } + [MaxLength(1024)] public string? Gender { get; set; } + [MaxLength(1024)] public string? Pronouns { get; set; } + [MaxLength(1024)] public string? TimeZone { get; set; } + [MaxLength(1024)] public string? Location { get; set; } + public Instant? Birthday { get; set; } + public Instant? LastSeenAt { get; set; } + + [Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; } + [Column(TypeName = "jsonb")] public BadgeReferenceObject? ActiveBadge { get; set; } + + public int Experience { get; set; } = 0; + [NotMapped] public int Level => Leveling.ExperiencePerLevel.Count(xp => Experience >= xp) - 1; + + [NotMapped] + public double LevelingProgress => Level >= Leveling.ExperiencePerLevel.Count - 1 + ? 100 + : (Experience - Leveling.ExperiencePerLevel[Level]) * 100.0 / + (Leveling.ExperiencePerLevel[Level + 1] - Leveling.ExperiencePerLevel[Level]); + + // Outdated fields, for backward compability + [MaxLength(32)] public string? PictureId { get; set; } + [MaxLength(32)] public string? BackgroundId { get; set; } + + [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; } + [Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; } + + public Guid AccountId { get; set; } + [JsonIgnore] public Account Account { get; set; } = null!; +} + +public class AccountContact : ModelBase +{ + public Guid Id { get; set; } + public AccountContactType Type { get; set; } + public Instant? VerifiedAt { get; set; } + public bool IsPrimary { get; set; } = false; + [MaxLength(1024)] public string Content { get; set; } = string.Empty; + + public Guid AccountId { get; set; } + [JsonIgnore] public Account Account { get; set; } = null!; +} + +public enum AccountContactType +{ + Email, + PhoneNumber, + Address +} + +public class AccountAuthFactor : ModelBase +{ + public Guid Id { get; set; } + public AccountAuthFactorType Type { get; set; } + [JsonIgnore] [MaxLength(8196)] public string? Secret { get; set; } + + [JsonIgnore] + [Column(TypeName = "jsonb")] + public Dictionary? Config { get; set; } = new(); + + /// + /// The trustworthy stands for how safe is this auth factor. + /// Basically, it affects how many steps it can complete in authentication. + /// Besides, users may need to use some high-trustworthy level auth factors when confirming some dangerous operations. + /// + public int Trustworthy { get; set; } = 1; + + public Instant? EnabledAt { get; set; } + public Instant? ExpiredAt { get; set; } + + public Guid AccountId { get; set; } + [JsonIgnore] public Account Account { get; set; } = null!; + + public AccountAuthFactor HashSecret(int cost = 12) + { + if (Secret == null) return this; + Secret = BCrypt.Net.BCrypt.HashPassword(Secret, workFactor: cost); + return this; + } + + public bool VerifyPassword(string password) + { + if (Secret == null) + throw new InvalidOperationException("Auth factor with no secret cannot be verified with password."); + switch (Type) + { + case AccountAuthFactorType.Password: + case AccountAuthFactorType.PinCode: + return BCrypt.Net.BCrypt.Verify(password, Secret); + case AccountAuthFactorType.TimedCode: + var otp = new Totp(Base32Encoding.ToBytes(Secret)); + return otp.VerifyTotp(DateTime.UtcNow, password, out _, new VerificationWindow(previous: 5, future: 5)); + case AccountAuthFactorType.EmailCode: + case AccountAuthFactorType.InAppCode: + default: + throw new InvalidOperationException("Unsupported verification type, use CheckDeliveredCode instead."); + } + } + + /// + /// This dictionary will be returned to the client and should only be set when it just created. + /// Useful for passing the client some data to finishing setup and recovery code. + /// + [NotMapped] + public Dictionary? CreatedResponse { get; set; } +} + +public enum AccountAuthFactorType +{ + Password, + EmailCode, + InAppCode, + TimedCode, + PinCode, +} + +public class AccountConnection : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + [MaxLength(4096)] public string Provider { get; set; } = null!; + [MaxLength(8192)] public string ProvidedIdentifier { get; set; } = null!; + [Column(TypeName = "jsonb")] public Dictionary? Meta { get; set; } = new(); + + [JsonIgnore] [MaxLength(4096)] public string? AccessToken { get; set; } + [JsonIgnore] [MaxLength(4096)] public string? RefreshToken { get; set; } + public Instant? LastUsedAt { get; set; } + + public Guid AccountId { get; set; } + public Account Account { get; set; } = null!; +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/AccountController.cs b/DysonNetwork.Pass/Account/AccountController.cs new file mode 100644 index 0000000..b6eabf3 --- /dev/null +++ b/DysonNetwork.Pass/Account/AccountController.cs @@ -0,0 +1,178 @@ +using System.ComponentModel.DataAnnotations; +using DysonNetwork.Pass.Auth; +using DysonNetwork.Pass; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NodaTime; +using NodaTime.Extensions; +using System.Collections.Generic; +using DysonNetwork.Pass.Account; + +namespace DysonNetwork.Pass.Account; + +[ApiController] +[Route("/api/accounts")] +public class AccountController( + AppDatabase db, + AuthService auth, + AccountService accounts, + AccountEventService events +) : ControllerBase +{ + [HttpGet("{name}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> 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>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> 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(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> 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 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> 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>> 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> 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(); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/AccountCurrentController.cs b/DysonNetwork.Pass/Account/AccountCurrentController.cs new file mode 100644 index 0000000..59642d2 --- /dev/null +++ b/DysonNetwork.Pass/Account/AccountCurrentController.cs @@ -0,0 +1,703 @@ +using System.ComponentModel.DataAnnotations; +using DysonNetwork.Pass.Auth; +using DysonNetwork.Pass; +using DysonNetwork.Pass.Storage; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NodaTime; +using Org.BouncyCastle.Utilities; + +namespace DysonNetwork.Pass.Account; + +[Authorize] +[ApiController] +[Route("/api/accounts/me")] +public class AccountCurrentController( + AppDatabase db, + AccountService accounts, + FileReferenceService fileRefService, + AccountEventService events, + AuthService auth +) : ControllerBase +{ + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetCurrentIdentity() + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + var userId = currentUser.Id; + + var account = await db.Accounts + .Include(e => e.Badges) + .Include(e => e.Profile) + .Where(e => e.Id == userId) + .FirstOrDefaultAsync(); + + return Ok(account); + } + + public class BasicInfoRequest + { + [MaxLength(256)] public string? Nick { get; set; } + [MaxLength(32)] public string? Language { get; set; } + } + + [HttpPatch] + public async Task> UpdateBasicInfo([FromBody] BasicInfoRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var account = await db.Accounts.FirstAsync(a => a.Id == currentUser.Id); + + if (request.Nick is not null) account.Nick = request.Nick; + if (request.Language is not null) account.Language = request.Language; + + await db.SaveChangesAsync(); + await accounts.PurgeAccountCache(currentUser); + return currentUser; + } + + public class ProfileRequest + { + [MaxLength(256)] public string? FirstName { get; set; } + [MaxLength(256)] public string? MiddleName { get; set; } + [MaxLength(256)] public string? LastName { get; set; } + [MaxLength(1024)] public string? Gender { get; set; } + [MaxLength(1024)] public string? Pronouns { get; set; } + [MaxLength(1024)] public string? TimeZone { get; set; } + [MaxLength(1024)] public string? Location { get; set; } + [MaxLength(4096)] public string? Bio { get; set; } + public Instant? Birthday { get; set; } + + [MaxLength(32)] public string? PictureId { get; set; } + [MaxLength(32)] public string? BackgroundId { get; set; } + } + + [HttpPatch("profile")] + public async Task> UpdateProfile([FromBody] ProfileRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + var userId = currentUser.Id; + + var profile = await db.AccountProfiles + .Where(p => p.Account.Id == userId) + .FirstOrDefaultAsync(); + if (profile is null) return BadRequest("Unable to get your account."); + + if (request.FirstName is not null) profile.FirstName = request.FirstName; + if (request.MiddleName is not null) profile.MiddleName = request.MiddleName; + if (request.LastName is not null) profile.LastName = request.LastName; + if (request.Bio is not null) profile.Bio = request.Bio; + if (request.Gender is not null) profile.Gender = request.Gender; + if (request.Pronouns is not null) profile.Pronouns = request.Pronouns; + if (request.Birthday is not null) profile.Birthday = request.Birthday; + if (request.Location is not null) profile.Location = request.Location; + if (request.TimeZone is not null) profile.TimeZone = request.TimeZone; + + if (request.PictureId is not null) + { + var picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync(); + if (picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud."); + + var profileResourceId = $"profile:{profile.Id}"; + + // Remove old references for the profile picture + if (profile.Picture is not null) + { + var oldPictureRefs = + await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.picture"); + foreach (var oldRef in oldPictureRefs) + { + await fileRefService.DeleteReferenceAsync(oldRef.Id); + } + } + + profile.Picture = picture.ToReferenceObject(); + + // Create new reference + await fileRefService.CreateReferenceAsync( + picture.Id, + "profile.picture", + profileResourceId + ); + } + + if (request.BackgroundId is not null) + { + var background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync(); + if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud."); + + var profileResourceId = $"profile:{profile.Id}"; + + // Remove old references for the profile background + if (profile.Background is not null) + { + var oldBackgroundRefs = + await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.background"); + foreach (var oldRef in oldBackgroundRefs) + { + await fileRefService.DeleteReferenceAsync(oldRef.Id); + } + } + + profile.Background = background.ToReferenceObject(); + + // Create new reference + await fileRefService.CreateReferenceAsync( + background.Id, + "profile.background", + profileResourceId + ); + } + + db.Update(profile); + await db.SaveChangesAsync(); + + await accounts.PurgeAccountCache(currentUser); + + return profile; + } + + [HttpDelete] + public async Task RequestDeleteAccount() + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + try + { + await accounts.RequestAccountDeletion(currentUser); + } + catch (InvalidOperationException) + { + return BadRequest("You already requested account deletion within 24 hours."); + } + + return Ok(); + } + + [HttpGet("statuses")] + public async Task> GetCurrentStatus() + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + var status = await events.GetStatus(currentUser.Id); + return Ok(status); + } + + [HttpPatch("statuses")] + [RequiredPermission("global", "accounts.statuses.update")] + public async Task> UpdateStatus([FromBody] AccountController.StatusRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var now = SystemClock.Instance.GetCurrentInstant(); + var status = await db.AccountStatuses + .Where(e => e.AccountId == currentUser.Id) + .Where(e => e.ClearedAt == null || e.ClearedAt > now) + .OrderByDescending(e => e.CreatedAt) + .FirstOrDefaultAsync(); + if (status is null) return NotFound(); + + status.Attitude = request.Attitude; + status.IsInvisible = request.IsInvisible; + status.IsNotDisturb = request.IsNotDisturb; + status.Label = request.Label; + status.ClearedAt = request.ClearedAt; + + db.Update(status); + await db.SaveChangesAsync(); + events.PurgeStatusCache(currentUser.Id); + + return status; + } + + [HttpPost("statuses")] + [RequiredPermission("global", "accounts.statuses.create")] + public async Task> CreateStatus([FromBody] AccountController.StatusRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var status = new Status + { + AccountId = currentUser.Id, + Attitude = request.Attitude, + IsInvisible = request.IsInvisible, + IsNotDisturb = request.IsNotDisturb, + Label = request.Label, + ClearedAt = request.ClearedAt + }; + + return await events.CreateStatus(currentUser, status); + } + + [HttpDelete("me/statuses")] + public async Task DeleteStatus() + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var now = SystemClock.Instance.GetCurrentInstant(); + var status = await db.AccountStatuses + .Where(s => s.AccountId == currentUser.Id) + .Where(s => s.ClearedAt == null || s.ClearedAt > now) + .OrderByDescending(s => s.CreatedAt) + .FirstOrDefaultAsync(); + if (status is null) return NotFound(); + + await events.ClearStatus(currentUser, status); + return NoContent(); + } + + [HttpGet("check-in")] + public async Task> GetCheckInResult() + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + var userId = currentUser.Id; + + var now = SystemClock.Instance.GetCurrentInstant(); + var today = now.InUtc().Date; + var startOfDay = today.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant(); + var endOfDay = today.PlusDays(1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant(); + + var result = await db.AccountCheckInResults + .Where(x => x.AccountId == userId) + .Where(x => x.CreatedAt >= startOfDay && x.CreatedAt < endOfDay) + .OrderByDescending(x => x.CreatedAt) + .FirstOrDefaultAsync(); + + return result is null ? NotFound() : Ok(result); + } + + [HttpPost("check-in")] + public async Task> DoCheckIn([FromBody] string? captchaToken) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var isAvailable = await events.CheckInDailyIsAvailable(currentUser); + if (!isAvailable) + return BadRequest("Check-in is not available for today."); + + try + { + var needsCaptcha = await events.CheckInDailyDoAskCaptcha(currentUser); + return needsCaptcha switch + { + true when string.IsNullOrWhiteSpace(captchaToken) => StatusCode(423, + "Captcha is required for this check-in."), + true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest("Invalid captcha token."), + _ => await events.CheckInDaily(currentUser) + }; + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpGet("calendar")] + public async Task>> GetEventCalendar([FromQuery] int? month, + [FromQuery] int? year) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var currentDate = SystemClock.Instance.GetCurrentInstant().InUtc().Date; + month ??= currentDate.Month; + year ??= currentDate.Year; + + if (month is < 1 or > 12) return BadRequest("Invalid month."); + if (year < 1) return BadRequest("Invalid year."); + + var calendar = await events.GetEventCalendar(currentUser, month.Value, year.Value); + return Ok(calendar); + } + + [HttpGet("actions")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task>> GetActionLogs( + [FromQuery] int take = 20, + [FromQuery] int offset = 0 + ) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var query = db.ActionLogs + .Where(log => log.AccountId == currentUser.Id) + .OrderByDescending(log => log.CreatedAt); + + var total = await query.CountAsync(); + Response.Headers.Append("X-Total", total.ToString()); + + var logs = await query + .Skip(offset) + .Take(take) + .ToListAsync(); + + return Ok(logs); + } + + [HttpGet("factors")] + public async Task>> GetAuthFactors() + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var factors = await db.AccountAuthFactors + .Include(f => f.Account) + .Where(f => f.Account.Id == currentUser.Id) + .ToListAsync(); + + return Ok(factors); + } + + public class AuthFactorRequest + { + public AccountAuthFactorType Type { get; set; } + public string? Secret { get; set; } + } + + [HttpPost("factors")] + [Authorize] + public async Task> CreateAuthFactor([FromBody] AuthFactorRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + if (await accounts.CheckAuthFactorExists(currentUser, request.Type)) + return BadRequest($"Auth factor with type {request.Type} is already exists."); + + var factor = await accounts.CreateAuthFactor(currentUser, request.Type, request.Secret); + return Ok(factor); + } + + [HttpPost("factors/{id:guid}/enable")] + [Authorize] + public async Task> EnableAuthFactor(Guid id, [FromBody] string? code) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var factor = await db.AccountAuthFactors + .Where(f => f.AccountId == currentUser.Id && f.Id == id) + .FirstOrDefaultAsync(); + if (factor is null) return NotFound(); + + try + { + factor = await accounts.EnableAuthFactor(factor, code); + return Ok(factor); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + + [HttpPost("factors/{id:guid}/disable")] + [Authorize] + public async Task> DisableAuthFactor(Guid id) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var factor = await db.AccountAuthFactors + .Where(f => f.AccountId == currentUser.Id && f.Id == id) + .FirstOrDefaultAsync(); + if (factor is null) return NotFound(); + + try + { + factor = await accounts.DisableAuthFactor(factor); + return Ok(factor); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + + [HttpDelete("factors/{id:guid}")] + [Authorize] + public async Task> DeleteAuthFactor(Guid id) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var factor = await db.AccountAuthFactors + .Where(f => f.AccountId == currentUser.Id && f.Id == id) + .FirstOrDefaultAsync(); + if (factor is null) return NotFound(); + + try + { + await accounts.DeleteAuthFactor(factor); + return NoContent(); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + + public class AuthorizedDevice + { + public string? Label { get; set; } + public string UserAgent { get; set; } = null!; + public string DeviceId { get; set; } = null!; + public ChallengePlatform Platform { get; set; } + public List Sessions { get; set; } = []; + } + + [HttpGet("devices")] + [Authorize] + public async Task>> GetDevices() + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser || + HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized(); + + Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString()); + + // Group sessions by the related DeviceId, then create an AuthorizedDevice for each group. + var deviceGroups = await db.AuthSessions + .Where(s => s.Account.Id == currentUser.Id) + .Include(s => s.Challenge) + .GroupBy(s => s.Challenge.DeviceId!) + .Select(g => new AuthorizedDevice + { + DeviceId = g.Key!, + UserAgent = g.First(x => x.Challenge.UserAgent != null).Challenge.UserAgent!, + Platform = g.First().Challenge.Platform!, + Label = g.Where(x => !string.IsNullOrWhiteSpace(x.Label)).Select(x => x.Label).FirstOrDefault(), + Sessions = g + .OrderByDescending(x => x.LastGrantedAt) + .ToList() + }) + .ToListAsync(); + deviceGroups = deviceGroups + .OrderByDescending(s => s.Sessions.First().LastGrantedAt) + .ToList(); + + return Ok(deviceGroups); + } + + [HttpGet("sessions")] + [Authorize] + public async Task>> GetSessions( + [FromQuery] int take = 20, + [FromQuery] int offset = 0 + ) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser || + HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized(); + + var query = db.AuthSessions + .Include(session => session.Account) + .Include(session => session.Challenge) + .Where(session => session.Account.Id == currentUser.Id); + + var total = await query.CountAsync(); + Response.Headers.Append("X-Total", total.ToString()); + Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString()); + + var sessions = await query + .OrderByDescending(x => x.LastGrantedAt) + .Skip(offset) + .Take(take) + .ToListAsync(); + + return Ok(sessions); + } + + [HttpDelete("sessions/{id:guid}")] + [Authorize] + public async Task> DeleteSession(Guid id) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + try + { + await accounts.DeleteSession(currentUser, id); + return NoContent(); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + + [HttpDelete("sessions/current")] + [Authorize] + public async Task> DeleteCurrentSession() + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser || + HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized(); + + try + { + await accounts.DeleteSession(currentUser, currentSession.Id); + return NoContent(); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + + [HttpPatch("sessions/{id:guid}/label")] + public async Task> UpdateSessionLabel(Guid id, [FromBody] string label) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + try + { + await accounts.UpdateSessionLabel(currentUser, id, label); + return NoContent(); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + + [HttpPatch("sessions/current/label")] + public async Task> UpdateCurrentSessionLabel([FromBody] string label) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser || + HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized(); + + try + { + await accounts.UpdateSessionLabel(currentUser, currentSession.Id, label); + return NoContent(); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + + [HttpGet("contacts")] + [Authorize] + public async Task>> GetContacts() + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var contacts = await db.AccountContacts + .Where(c => c.AccountId == currentUser.Id) + .ToListAsync(); + + return Ok(contacts); + } + + public class AccountContactRequest + { + [Required] public AccountContactType Type { get; set; } + [Required] public string Content { get; set; } = null!; + } + + [HttpPost("contacts")] + [Authorize] + public async Task> CreateContact([FromBody] AccountContactRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + try + { + var contact = await accounts.CreateContactMethod(currentUser, request.Type, request.Content); + return Ok(contact); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + + [HttpPost("contacts/{id:guid}/verify")] + [Authorize] + public async Task> VerifyContact(Guid id) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var contact = await db.AccountContacts + .Where(c => c.AccountId == currentUser.Id && c.Id == id) + .FirstOrDefaultAsync(); + if (contact is null) return NotFound(); + + try + { + await accounts.VerifyContactMethod(currentUser, contact); + return Ok(contact); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + + [HttpPost("contacts/{id:guid}/primary")] + [Authorize] + public async Task> SetPrimaryContact(Guid id) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var contact = await db.AccountContacts + .Where(c => c.AccountId == currentUser.Id && c.Id == id) + .FirstOrDefaultAsync(); + if (contact is null) return NotFound(); + + try + { + contact = await accounts.SetContactMethodPrimary(currentUser, contact); + return Ok(contact); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + + [HttpDelete("contacts/{id:guid}")] + [Authorize] + public async Task> DeleteContact(Guid id) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var contact = await db.AccountContacts + .Where(c => c.AccountId == currentUser.Id && c.Id == id) + .FirstOrDefaultAsync(); + if (contact is null) return NotFound(); + + try + { + await accounts.DeleteContactMethod(currentUser, contact); + return NoContent(); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + + [HttpGet("badges")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [Authorize] + public async Task>> GetBadges() + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var badges = await db.Badges + .Where(b => b.AccountId == currentUser.Id) + .ToListAsync(); + return Ok(badges); + } + + [HttpPost("badges/{id:guid}/active")] + [Authorize] + public async Task> ActivateBadge(Guid id) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + try + { + await accounts.ActiveBadge(currentUser, id); + return Ok(); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/AccountEventService.cs b/DysonNetwork.Pass/Account/AccountEventService.cs new file mode 100644 index 0000000..b8fdf9d --- /dev/null +++ b/DysonNetwork.Pass/Account/AccountEventService.cs @@ -0,0 +1,339 @@ +using System.Globalization; +using DysonNetwork.Pass; +using DysonNetwork.Pass.Connection; +using DysonNetwork.Pass.Storage; +using DysonNetwork.Pass.Wallet; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Localization; +using NodaTime; +using Org.BouncyCastle.Asn1.X509; + +namespace DysonNetwork.Pass.Account; + +public class AccountEventService( + AppDatabase db, + WebSocketService ws, + ICacheService cache, + PaymentService payment, + IStringLocalizer 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 GetStatus(Guid userId) + { + var cacheKey = $"{StatusCacheKey}{userId}"; + var cachedStatus = await cache.GetAsync(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> GetStatuses(List userIds) + { + var results = new Dictionary(); + var cacheMissUserIds = new List(); + + foreach (var userId in userIds) + { + var cacheKey = $"{StatusCacheKey}{userId}"; + var cachedStatus = await cache.GetAsync(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(); + + 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 CreateStatus(Account user, Status status) + { + var now = SystemClock.Instance.GetCurrentInstant(); + await db.AccountStatuses + .Where(x => x.AccountId == user.Id && (x.ClearedAt == null || x.ClearedAt > now)) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.ClearedAt, now)); + + db.AccountStatuses.Add(status); + await db.SaveChangesAsync(); + + return status; + } + + public async Task ClearStatus(Account user, Status status) + { + status.ClearedAt = SystemClock.Instance.GetCurrentInstant(); + db.Update(status); + await db.SaveChangesAsync(); + PurgeStatusCache(user.Id); + } + + private const int FortuneTipCount = 7; // This will be the max index for each type (positive/negative) + private const string CaptchaCacheKey = "CheckInCaptcha_"; + private const int CaptchaProbabilityPercent = 20; + + public async Task CheckInDailyDoAskCaptcha(Account user) + { + var cacheKey = $"{CaptchaCacheKey}{user.Id}"; + var needsCaptcha = await cache.GetAsync(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 CheckInDailyIsAvailable(Account user) + { + var now = SystemClock.Instance.GetCurrentInstant(); + var lastCheckIn = await db.AccountCheckInResults + .Where(x => x.AccountId == user.Id) + .OrderByDescending(x => x.CreatedAt) + .FirstOrDefaultAsync(); + + if (lastCheckIn == null) + return true; + + var lastDate = lastCheckIn.CreatedAt.InUtc().Date; + var currentDate = now.InUtc().Date; + + return lastDate < currentDate; + } + + public const string CheckInLockKey = "CheckInLock_"; + + public async Task CheckInDaily(Account user) + { + var lockKey = $"{CheckInLockKey}{user.Id}"; + + try + { + var lk = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(100)); + + if (lk != null) + await lk.ReleaseAsync(); + } + catch + { + // Ignore errors from this pre-check + } + + // Now try to acquire the lock properly + await using var lockObj = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5)); + if (lockObj is null) throw new InvalidOperationException("Check-in was in progress."); + + var cultureInfo = new CultureInfo(user.Language, false); + CultureInfo.CurrentCulture = cultureInfo; + CultureInfo.CurrentUICulture = cultureInfo; + + // Generate 2 positive tips + var positiveIndices = Enumerable.Range(1, FortuneTipCount) + .OrderBy(_ => Random.Next()) + .Take(2) + .ToList(); + var tips = positiveIndices.Select(index => new FortuneTip + { + IsPositive = true, Title = localizer[$"FortuneTipPositiveTitle_{index}"].Value, + Content = localizer[$"FortuneTipPositiveContent_{index}"].Value + }).ToList(); + + // Generate 2 negative tips + var negativeIndices = Enumerable.Range(1, FortuneTipCount) + .Except(positiveIndices) + .OrderBy(_ => Random.Next()) + .Take(2) + .ToList(); + tips.AddRange(negativeIndices.Select(index => new FortuneTip + { + IsPositive = false, Title = localizer[$"FortuneTipNegativeTitle_{index}"].Value, + Content = localizer[$"FortuneTipNegativeContent_{index}"].Value + })); + + var result = new CheckInResult + { + Tips = tips, + Level = (CheckInResultLevel)Random.Next(Enum.GetValues().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> GetEventCalendar(Account user, int month, int year = 0, + bool replaceInvisible = false) + { + if (year == 0) + year = SystemClock.Instance.GetCurrentInstant().InUtc().Date.Year; + + // Create start and end dates for the specified month + var startOfMonth = new LocalDate(year, month, 1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant(); + var endOfMonth = startOfMonth.Plus(Duration.FromDays(DateTime.DaysInMonth(year, month))); + + var statuses = await db.AccountStatuses + .AsNoTracking() + .TagWith("GetEventCalendar_Statuses") + .Where(x => x.AccountId == user.Id && x.CreatedAt >= startOfMonth && x.CreatedAt < endOfMonth) + .Select(x => new Status + { + Id = x.Id, + Attitude = x.Attitude, + IsInvisible = !replaceInvisible && x.IsInvisible, + IsNotDisturb = x.IsNotDisturb, + Label = x.Label, + ClearedAt = x.ClearedAt, + AccountId = x.AccountId, + CreatedAt = x.CreatedAt + }) + .OrderBy(x => x.CreatedAt) + .ToListAsync(); + + var checkIn = await db.AccountCheckInResults + .AsNoTracking() + .TagWith("GetEventCalendar_CheckIn") + .Where(x => x.AccountId == user.Id && x.CreatedAt >= startOfMonth && x.CreatedAt < endOfMonth) + .ToListAsync(); + + var dates = Enumerable.Range(1, DateTime.DaysInMonth(year, month)) + .Select(day => new LocalDate(year, month, day).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant()) + .ToList(); + + var statusesByDate = statuses + .GroupBy(s => s.CreatedAt.InUtc().Date) + .ToDictionary(g => g.Key, g => g.ToList()); + + var checkInByDate = checkIn + .ToDictionary(c => c.CreatedAt.InUtc().Date); + + return dates.Select(date => + { + var utcDate = date.InUtc().Date; + return new DailyEventResponse + { + Date = date, + CheckInResult = checkInByDate.GetValueOrDefault(utcDate), + Statuses = statusesByDate.GetValueOrDefault(utcDate, new List()) + }; + }).ToList(); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/AccountService.cs b/DysonNetwork.Pass/Account/AccountService.cs new file mode 100644 index 0000000..ed80166 --- /dev/null +++ b/DysonNetwork.Pass/Account/AccountService.cs @@ -0,0 +1,657 @@ +using System.Globalization; +using DysonNetwork.Pass; +using DysonNetwork.Pass.Auth.OpenId; +using DysonNetwork.Pass.Email; + +using DysonNetwork.Pass.Localization; +using DysonNetwork.Pass.Permission; +using DysonNetwork.Pass.Storage; +using EFCore.BulkExtensions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Localization; +using NodaTime; +using Org.BouncyCastle.Utilities; +using OtpNet; + +namespace DysonNetwork.Pass.Account; + +public class AccountService( + AppDatabase db, + MagicSpellService spells, + AccountUsernameService uname, + NotificationService nty, + EmailService mailer, + IStringLocalizer localizer, + ICacheService cache, + ILogger logger +) +{ + public static void SetCultureInfo(Account account) + { + SetCultureInfo(account.Language); + } + + public static void SetCultureInfo(string? languageCode) + { + var info = new CultureInfo(languageCode ?? "en-us", false); + CultureInfo.CurrentCulture = info; + CultureInfo.CurrentUICulture = info; + } + + public const string AccountCachePrefix = "account:"; + + public async Task PurgeAccountCache(Account account) + { + await cache.RemoveGroupAsync($"{AccountCachePrefix}{account.Id}"); + } + + public async Task 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 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 GetAccountLevel(Guid accountId) + { + var profile = await db.AccountProfiles + .Where(a => a.AccountId == accountId) + .FirstOrDefaultAsync(); + return profile?.Level; + } + + public async Task CreateAccount( + string name, + string nick, + string email, + string? password, + string language = "en-US", + bool isEmailVerified = false, + bool isActivated = false + ) + { + await using var transaction = await db.Database.BeginTransactionAsync(); + try + { + var dupeNameCount = await db.Accounts.Where(a => a.Name == name).CountAsync(); + if (dupeNameCount > 0) + throw new InvalidOperationException("Account name has already been taken."); + + var account = new Account + { + Name = name, + Nick = nick, + Language = language, + Contacts = new List + { + new() + { + Type = AccountContactType.Email, + Content = email, + VerifiedAt = isEmailVerified ? SystemClock.Instance.GetCurrentInstant() : null, + IsPrimary = true + } + }, + AuthFactors = password is not null + ? new List + { + 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 + { + { "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 CreateAccount(OidcUserInfo userInfo) + { + if (string.IsNullOrEmpty(userInfo.Email)) + throw new ArgumentException("Email is required for account creation"); + + var displayName = !string.IsNullOrEmpty(userInfo.DisplayName) + ? userInfo.DisplayName + : $"{userInfo.FirstName} {userInfo.LastName}".Trim(); + + // Generate username from email + var username = await uname.GenerateUsernameFromEmailAsync(userInfo.Email); + + return await CreateAccount( + username, + displayName, + userInfo.Email, + null, + "en-US", + userInfo.EmailVerified, + userInfo.EmailVerified + ); + } + + public async Task RequestAccountDeletion(Account account) + { + var spell = await spells.CreateMagicSpell( + account, + MagicSpellType.AccountRemoval, + new Dictionary(), + SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)), + preventRepeat: true + ); + await spells.NotifyMagicSpell(spell); + } + + public async Task RequestPasswordReset(Account account) + { + var spell = await spells.CreateMagicSpell( + account, + MagicSpellType.AuthPasswordReset, + new Dictionary(), + SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)), + preventRepeat: true + ); + await spells.NotifyMagicSpell(spell); + } + + public async Task CheckAuthFactorExists(Account account, AccountAuthFactorType type) + { + var isExists = await db.AccountAuthFactors + .Where(x => x.AccountId == account.Id && x.Type == type) + .AnyAsync(); + return isExists; + } + + public async Task CreateAuthFactor(Account account, AccountAuthFactorType type, string? secret) + { + AccountAuthFactor? factor = null; + switch (type) + { + case AccountAuthFactorType.Password: + if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentNullException(nameof(secret)); + factor = new AccountAuthFactor + { + Type = AccountAuthFactorType.Password, + Trustworthy = 1, + AccountId = account.Id, + Secret = secret, + EnabledAt = SystemClock.Instance.GetCurrentInstant(), + }.HashSecret(); + break; + case AccountAuthFactorType.EmailCode: + factor = new AccountAuthFactor + { + Type = AccountAuthFactorType.EmailCode, + Trustworthy = 2, + EnabledAt = SystemClock.Instance.GetCurrentInstant(), + }; + break; + case AccountAuthFactorType.InAppCode: + factor = new AccountAuthFactor + { + Type = AccountAuthFactorType.InAppCode, + Trustworthy = 1, + EnabledAt = SystemClock.Instance.GetCurrentInstant() + }; + break; + case AccountAuthFactorType.TimedCode: + var skOtp = KeyGeneration.GenerateRandomKey(20); + var skOtp32 = Base32Encoding.ToString(skOtp); + factor = new AccountAuthFactor + { + Secret = skOtp32, + Type = AccountAuthFactorType.TimedCode, + Trustworthy = 2, + EnabledAt = null, // It needs to be tired once to enable + CreatedResponse = new Dictionary + { + ["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 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 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(); + } + + /// + /// 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. + /// + /// The owner of the auth factor + /// The auth factor needed to send code + /// The part of the contact method for verification + public async Task SendFactorCode(Account account, AccountAuthFactor factor, string? hint = null) + { + var code = new Random().Next(100000, 999999).ToString("000000"); + + switch (factor.Type) + { + case AccountAuthFactorType.InAppCode: + if (await _GetFactorCode(factor) is not null) + throw new InvalidOperationException("A factor code has been sent and in active duration."); + + await nty.SendNotification( + account, + "auth.verification", + localizer["AuthCodeTitle"], + null, + localizer["AuthCodeBody", code], + save: true + ); + await _SetFactorCode(factor, code, TimeSpan.FromMinutes(5)); + break; + case AccountAuthFactorType.EmailCode: + if (await _GetFactorCode(factor) is not null) + throw new InvalidOperationException("A factor code has been sent and in active duration."); + + ArgumentNullException.ThrowIfNull(hint); + hint = hint.Replace("@", "").Replace(".", "").Replace("+", "").Replace("%", ""); + if (string.IsNullOrWhiteSpace(hint)) + { + logger.LogWarning( + "Unable to send factor code to #{FactorId} with hint {Hint}, due to invalid hint...", + factor.Id, + hint + ); + return; + } + + var contact = await db.AccountContacts + .Where(c => c.Type == AccountContactType.Email) + .Where(c => c.VerifiedAt != null) + .Where(c => EF.Functions.ILike(c.Content, $"%{hint}%")) + .Include(c => c.Account) + .FirstOrDefaultAsync(); + if (contact is null) + { + logger.LogWarning( + "Unable to send factor code to #{FactorId} with hint {Hint}, due to no contact method found according to hint...", + factor.Id, + hint + ); + return; + } + + await mailer.SendTemplatedEmailAsync( + 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 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 _GetFactorCode(AccountAuthFactor factor) + { + return await cache.GetAsync( + $"{AuthFactorCachePrefix}{factor.Id}:code" + ); + } + + public async Task UpdateSessionLabel(Account account, Guid sessionId, string label) + { + var session = await db.AuthSessions + .Include(s => s.Challenge) + .Where(s => s.Id == sessionId && s.AccountId == account.Id) + .FirstOrDefaultAsync(); + if (session is null) throw new InvalidOperationException("Session was not found."); + + await db.AuthSessions + .Include(s => s.Challenge) + .Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId) + .ExecuteUpdateAsync(p => p.SetProperty(s => s.Label, label)); + + var sessions = await db.AuthSessions + .Include(s => s.Challenge) + .Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId) + .ToListAsync(); + foreach (var item in sessions) + await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}"); + + return session; + } + + public async Task DeleteSession(Account account, Guid sessionId) + { + var session = await db.AuthSessions + .Include(s => s.Challenge) + .Where(s => s.Id == sessionId && s.AccountId == account.Id) + .FirstOrDefaultAsync(); + if (session is null) throw new InvalidOperationException("Session was not found."); + + var sessions = await db.AuthSessions + .Include(s => s.Challenge) + .Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId) + .ToListAsync(); + + if (session.Challenge.DeviceId is not null) + await nty.UnsubscribePushNotifications(session.Challenge.DeviceId); + + // The current session should be included in the sessions' list + await db.AuthSessions + .Include(s => s.Challenge) + .Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId) + .ExecuteDeleteAsync(); + + foreach (var item in sessions) + await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}"); + } + + public async Task CreateContactMethod(Account account, AccountContactType type, string content) + { + var contact = new AccountContact + { + Type = type, + Content = content, + AccountId = account.Id, + }; + + db.AccountContacts.Add(contact); + await db.SaveChangesAsync(); + + return contact; + } + + public async Task VerifyContactMethod(Account account, AccountContact contact) + { + var spell = await spells.CreateMagicSpell( + account, + MagicSpellType.ContactVerification, + new Dictionary { { "contact_method", contact.Content } }, + expiredAt: SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)), + preventRepeat: true + ); + await spells.NotifyMagicSpell(spell); + } + + public async Task SetContactMethodPrimary(Account account, AccountContact contact) + { + if (contact.AccountId != account.Id) + throw new InvalidOperationException("Contact method does not belong to this account."); + if (contact.VerifiedAt is null) + throw new InvalidOperationException("Cannot set unverified contact method as primary."); + + await using var transaction = await db.Database.BeginTransactionAsync(); + + try + { + await db.AccountContacts + .Where(c => c.AccountId == account.Id && c.Type == contact.Type) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.IsPrimary, false)); + + contact.IsPrimary = true; + db.AccountContacts.Update(contact); + await db.SaveChangesAsync(); + + await transaction.CommitAsync(); + return contact; + } + catch + { + await transaction.RollbackAsync(); + throw; + } + } + + public async Task DeleteContactMethod(Account account, AccountContact contact) + { + if (contact.AccountId != account.Id) + throw new InvalidOperationException("Contact method does not belong to this account."); + if (contact.IsPrimary) + throw new InvalidOperationException("Cannot delete primary contact method."); + + db.AccountContacts.Remove(contact); + await db.SaveChangesAsync(); + } + + /// + /// This method will grant a badge to the account. + /// Shouldn't be exposed to normal user and the user itself. + /// + public async Task GrantBadge(Account account, Badge badge) + { + badge.AccountId = account.Id; + db.Badges.Add(badge); + await db.SaveChangesAsync(); + return badge; + } + + /// + /// This method will revoke a badge from the account. + /// Shouldn't be exposed to normal user and the user itself. + /// + public async Task RevokeBadge(Account account, Guid badgeId) + { + var badge = await db.Badges + .Where(b => b.AccountId == account.Id && b.Id == badgeId) + .OrderByDescending(b => b.CreatedAt) + .FirstOrDefaultAsync(); + if (badge is null) throw new InvalidOperationException("Badge was not found."); + + var profile = await db.AccountProfiles + .Where(p => p.AccountId == account.Id) + .FirstOrDefaultAsync(); + if (profile?.ActiveBadge is not null && profile.ActiveBadge.Id == badge.Id) + profile.ActiveBadge = null; + + db.Remove(badge); + await db.SaveChangesAsync(); + } + + public async Task ActiveBadge(Account account, Guid badgeId) + { + await using var transaction = await db.Database.BeginTransactionAsync(); + + try + { + var badge = await db.Badges + .Where(b => b.AccountId == account.Id && b.Id == badgeId) + .OrderByDescending(b => b.CreatedAt) + .FirstOrDefaultAsync(); + if (badge is null) throw new InvalidOperationException("Badge was not found."); + + await db.Badges + .Where(b => b.AccountId == account.Id && b.Id != badgeId) + .ExecuteUpdateAsync(s => s.SetProperty(p => p.ActivatedAt, p => null)); + + badge.ActivatedAt = SystemClock.Instance.GetCurrentInstant(); + db.Update(badge); + await db.SaveChangesAsync(); + + await db.AccountProfiles + .Where(p => p.AccountId == account.Id) + .ExecuteUpdateAsync(s => s.SetProperty(p => p.ActiveBadge, badge.ToReference())); + await PurgeAccountCache(account); + + await transaction.CommitAsync(); + } + catch + { + await transaction.RollbackAsync(); + throw; + } + } + + /// + /// The maintenance method for server administrator. + /// To check every user has an account profile and to create them if it isn't having one. + /// + 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); + } + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/AccountUsernameService.cs b/DysonNetwork.Pass/Account/AccountUsernameService.cs new file mode 100644 index 0000000..25ffcaa --- /dev/null +++ b/DysonNetwork.Pass/Account/AccountUsernameService.cs @@ -0,0 +1,105 @@ +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; + +namespace DysonNetwork.Pass.Account; + +/// +/// Service for handling username generation and validation +/// +public class AccountUsernameService(AppDatabase db) +{ + private readonly Random _random = new(); + + /// + /// Generates a unique username based on the provided base name + /// + /// The preferred username + /// A unique username + public async Task 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}"; + } + + /// + /// Sanitizes a username by removing invalid characters and converting to lowercase + /// + 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; + } + + /// + /// Checks if a username already exists + /// + public async Task IsUsernameExistsAsync(string username) + { + return await db.Accounts.AnyAsync(a => a.Name == username); + } + + /// + /// Generates a username from an email address + /// + /// The email address to generate a username from + /// A unique username derived from the email + public async Task 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); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/ActionLog.cs b/DysonNetwork.Pass/Account/ActionLog.cs new file mode 100644 index 0000000..03a4d02 --- /dev/null +++ b/DysonNetwork.Pass/Account/ActionLog.cs @@ -0,0 +1,58 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Point = NetTopologySuite.Geometries.Point; + +namespace DysonNetwork.Pass.Account; + +public abstract class ActionLogType +{ + public const string NewLogin = "login"; + public const string ChallengeAttempt = "challenges.attempt"; + public const string ChallengeSuccess = "challenges.success"; + public const string ChallengeFailure = "challenges.failure"; + public const string PostCreate = "posts.create"; + public const string PostUpdate = "posts.update"; + public const string PostDelete = "posts.delete"; + public const string PostReact = "posts.react"; + public const string MessageCreate = "messages.create"; + public const string MessageUpdate = "messages.update"; + public const string MessageDelete = "messages.delete"; + public const string MessageReact = "messages.react"; + public const string PublisherCreate = "publishers.create"; + public const string PublisherUpdate = "publishers.update"; + public const string PublisherDelete = "publishers.delete"; + public const string PublisherMemberInvite = "publishers.members.invite"; + public const string PublisherMemberJoin = "publishers.members.join"; + public const string PublisherMemberLeave = "publishers.members.leave"; + public const string PublisherMemberKick = "publishers.members.kick"; + public const string RealmCreate = "realms.create"; + public const string RealmUpdate = "realms.update"; + public const string RealmDelete = "realms.delete"; + public const string RealmInvite = "realms.invite"; + public const string RealmJoin = "realms.join"; + public const string RealmLeave = "realms.leave"; + public const string RealmKick = "realms.kick"; + public const string RealmAdjustRole = "realms.role.edit"; + public const string ChatroomCreate = "chatrooms.create"; + public const string ChatroomUpdate = "chatrooms.update"; + public const string ChatroomDelete = "chatrooms.delete"; + public const string ChatroomInvite = "chatrooms.invite"; + public const string ChatroomJoin = "chatrooms.join"; + public const string ChatroomLeave = "chatrooms.leave"; + public const string ChatroomKick = "chatrooms.kick"; + public const string ChatroomAdjustRole = "chatrooms.role.edit"; +} + +public class ActionLog : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + [MaxLength(4096)] public string Action { get; set; } = null!; + [Column(TypeName = "jsonb")] public Dictionary Meta { get; set; } = new(); + [MaxLength(512)] public string? UserAgent { get; set; } + [MaxLength(128)] public string? IpAddress { get; set; } + public Point? Location { get; set; } + + public Guid AccountId { get; set; } + public Account Account { get; set; } = null!; + public Guid? SessionId { get; set; } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/ActionLogService.cs b/DysonNetwork.Pass/Account/ActionLogService.cs new file mode 100644 index 0000000..a85ff6d --- /dev/null +++ b/DysonNetwork.Pass/Account/ActionLogService.cs @@ -0,0 +1,46 @@ +using Quartz; +using DysonNetwork.Pass; +using DysonNetwork.Pass.Storage; +using DysonNetwork.Pass.Storage.Handlers; + +namespace DysonNetwork.Pass.Account; + +public class ActionLogService(GeoIpService geo, FlushBufferService fbs) +{ + public void CreateActionLog(Guid accountId, string action, Dictionary meta) + { + var log = new ActionLog + { + Action = action, + AccountId = accountId, + Meta = meta, + }; + + fbs.Enqueue(log); + } + + public void CreateActionLogFromRequest(string action, Dictionary meta, HttpRequest request, + Account? account = null) + { + var log = new ActionLog + { + Action = action, + Meta = meta, + UserAgent = request.Headers.UserAgent, + IpAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString(), + Location = geo.GetPointFromIp(request.HttpContext.Connection.RemoteIpAddress?.ToString()) + }; + + if (request.HttpContext.Items["CurrentUser"] is Account currentUser) + log.AccountId = currentUser.Id; + else if (account != null) + log.AccountId = account.Id; + else + throw new ArgumentException("No user context was found"); + + if (request.HttpContext.Items["CurrentSession"] is Auth.Session currentSession) + log.SessionId = currentSession.Id; + + fbs.Enqueue(log); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/Badge.cs b/DysonNetwork.Pass/Account/Badge.cs new file mode 100644 index 0000000..7f15899 --- /dev/null +++ b/DysonNetwork.Pass/Account/Badge.cs @@ -0,0 +1,47 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; +using NodaTime; + +namespace DysonNetwork.Pass.Account; + +public class Badge : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + [MaxLength(1024)] public string Type { get; set; } = null!; + [MaxLength(1024)] public string? Label { get; set; } + [MaxLength(4096)] public string? Caption { get; set; } + [Column(TypeName = "jsonb")] public Dictionary Meta { get; set; } = new(); + public Instant? ActivatedAt { get; set; } + public Instant? ExpiredAt { get; set; } + + public Guid AccountId { get; set; } + [JsonIgnore] public Account Account { get; set; } = null!; + + public BadgeReferenceObject ToReference() + { + return new BadgeReferenceObject + { + Id = Id, + Type = Type, + Label = Label, + Caption = Caption, + Meta = Meta, + ActivatedAt = ActivatedAt, + ExpiredAt = ExpiredAt, + AccountId = AccountId + }; + } +} + +public class BadgeReferenceObject : ModelBase +{ + public Guid Id { get; set; } + public string Type { get; set; } = null!; + public string? Label { get; set; } + public string? Caption { get; set; } + public Dictionary? Meta { get; set; } + public Instant? ActivatedAt { get; set; } + public Instant? ExpiredAt { get; set; } + public Guid AccountId { get; set; } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/Event.cs b/DysonNetwork.Pass/Account/Event.cs new file mode 100644 index 0000000..cbf63f5 --- /dev/null +++ b/DysonNetwork.Pass/Account/Event.cs @@ -0,0 +1,65 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using NodaTime; + +namespace DysonNetwork.Pass.Account; + +public enum StatusAttitude +{ + Positive, + Negative, + Neutral +} + +public class Status : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public StatusAttitude Attitude { get; set; } + [NotMapped] public bool IsOnline { get; set; } + [NotMapped] public bool IsCustomized { get; set; } = true; + public bool IsInvisible { get; set; } + public bool IsNotDisturb { get; set; } + [MaxLength(1024)] public string? Label { get; set; } + public Instant? ClearedAt { get; set; } + + public Guid AccountId { get; set; } + public Account Account { get; set; } = null!; +} + +public enum CheckInResultLevel +{ + Worst, + Worse, + Normal, + Better, + Best +} + +public class CheckInResult : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public CheckInResultLevel Level { get; set; } + public decimal? RewardPoints { get; set; } + public int? RewardExperience { get; set; } + [Column(TypeName = "jsonb")] public ICollection Tips { get; set; } = new List(); + + public Guid AccountId { get; set; } + public Account Account { get; set; } = null!; +} + +public class FortuneTip +{ + public bool IsPositive { get; set; } + public string Title { get; set; } = null!; + public string Content { get; set; } = null!; +} + +/// +/// This method should not be mapped. Used to generate the daily event calendar. +/// +public class DailyEventResponse +{ + public Instant Date { get; set; } + public CheckInResult? CheckInResult { get; set; } + public ICollection Statuses { get; set; } = new List(); +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/MagicSpell.cs b/DysonNetwork.Pass/Account/MagicSpell.cs new file mode 100644 index 0000000..37f19be --- /dev/null +++ b/DysonNetwork.Pass/Account/MagicSpell.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace DysonNetwork.Pass.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 Meta { get; set; } = new(); + + public Guid? AccountId { get; set; } + public Account? Account { get; set; } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/MagicSpellController.cs b/DysonNetwork.Pass/Account/MagicSpellController.cs new file mode 100644 index 0000000..ec1a905 --- /dev/null +++ b/DysonNetwork.Pass/Account/MagicSpellController.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Mvc; + +namespace DysonNetwork.Pass.Account; + +[ApiController] +[Route("/api/spells")] +public class MagicSpellController(AppDatabase db, MagicSpellService sp) : ControllerBase +{ + [HttpPost("{spellId:guid}/resend")] + public async Task ResendMagicSpell(Guid spellId) + { + var spell = db.MagicSpells.FirstOrDefault(x => x.Id == spellId); + if (spell == null) + return NotFound(); + + await sp.NotifyMagicSpell(spell, true); + return Ok(); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/MagicSpellService.cs b/DysonNetwork.Pass/Account/MagicSpellService.cs new file mode 100644 index 0000000..6140c1f --- /dev/null +++ b/DysonNetwork.Pass/Account/MagicSpellService.cs @@ -0,0 +1,252 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using DysonNetwork.Pass; +using DysonNetwork.Pass.Pages.Emails; +using DysonNetwork.Pass.Permission; +using DysonNetwork.Pass.Resources.Localization; +using DysonNetwork.Pass.Resources.Pages.Emails; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Localization; +using NodaTime; + +namespace DysonNetwork.Pass.Account; + +public class MagicSpellService( + AppDatabase db, + EmailService email, + IConfiguration configuration, + ILogger logger, + IStringLocalizer localizer +) +{ + public async Task CreateMagicSpell( + Account account, + MagicSpellType type, + Dictionary 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("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( + contact.Account.Nick, + contact.Content, + localizer["EmailLandingTitle"], + new LandingEmailModel + { + Name = contact.Account.Name, + Link = link + } + ); + break; + case MagicSpellType.AccountRemoval: + await email.SendTemplatedEmailAsync( + contact.Account.Nick, + contact.Content, + localizer["EmailAccountDeletionTitle"], + new AccountDeletionEmailModel + { + Name = contact.Account.Name, + Link = link + } + ); + break; + case MagicSpellType.AuthPasswordReset: + await email.SendTemplatedEmailAsync( + 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( + 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); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/Notification.cs b/DysonNetwork.Pass/Account/Notification.cs new file mode 100644 index 0000000..f2e2c8e --- /dev/null +++ b/DysonNetwork.Pass/Account/Notification.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace DysonNetwork.Pass.Account; + +public class Notification : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + [MaxLength(1024)] public string Topic { get; set; } = null!; + [MaxLength(1024)] public string? Title { get; set; } + [MaxLength(2048)] public string? Subtitle { get; set; } + [MaxLength(4096)] public string? Content { get; set; } + [Column(TypeName = "jsonb")] public Dictionary? Meta { get; set; } + public int Priority { get; set; } = 10; + public Instant? ViewedAt { get; set; } + + public Guid AccountId { get; set; } + [JsonIgnore] public Account Account { get; set; } = null!; +} + +public enum NotificationPushProvider +{ + Apple, + Google +} + +[Index(nameof(DeviceToken), nameof(DeviceId), nameof(AccountId), IsUnique = true)] +public class NotificationPushSubscription : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + [MaxLength(4096)] public string DeviceId { get; set; } = null!; + [MaxLength(4096)] public string DeviceToken { get; set; } = null!; + public NotificationPushProvider Provider { get; set; } + public Instant? LastUsedAt { get; set; } + + public Guid AccountId { get; set; } + [JsonIgnore] public Account Account { get; set; } = null!; +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/NotificationController.cs b/DysonNetwork.Pass/Account/NotificationController.cs new file mode 100644 index 0000000..8ad4681 --- /dev/null +++ b/DysonNetwork.Pass/Account/NotificationController.cs @@ -0,0 +1,166 @@ +using System.ComponentModel.DataAnnotations; +using DysonNetwork.Pass; +using DysonNetwork.Pass.Permission; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace DysonNetwork.Pass.Account; + +[ApiController] +[Route("/api/notifications")] +public class NotificationController(AppDatabase db, NotificationService nty) : ControllerBase +{ + [HttpGet("count")] + [Authorize] + public async Task> CountUnreadNotifications() + { + HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); + if (currentUserValue is not Account currentUser) return Unauthorized(); + + var count = await db.Notifications + .Where(s => s.AccountId == currentUser.Id && s.ViewedAt == null) + .CountAsync(); + return Ok(count); + } + + [HttpGet] + [Authorize] + public async Task>> ListNotifications( + [FromQuery] int offset = 0, + // The page size set to 5 is to avoid the client pulled the notification + // but didn't render it in the screen-viewable region. + [FromQuery] int take = 5 + ) + { + HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); + if (currentUserValue is not Account currentUser) return Unauthorized(); + + var totalCount = await db.Notifications + .Where(s => s.AccountId == currentUser.Id) + .CountAsync(); + var notifications = await db.Notifications + .Where(s => s.AccountId == currentUser.Id) + .OrderByDescending(e => e.CreatedAt) + .Skip(offset) + .Take(take) + .ToListAsync(); + + Response.Headers["X-Total"] = totalCount.ToString(); + await nty.MarkNotificationsViewed(notifications); + + return Ok(notifications); + } + + public class PushNotificationSubscribeRequest + { + [MaxLength(4096)] public string DeviceToken { get; set; } = null!; + public NotificationPushProvider Provider { get; set; } + } + + [HttpPut("subscription")] + [Authorize] + public async Task> SubscribeToPushNotification( + [FromBody] PushNotificationSubscribeRequest request + ) + { + HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue); + HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); + var currentUser = currentUserValue as Account; + if (currentUser == null) return Unauthorized(); + var currentSession = currentSessionValue as Session; + if (currentSession == null) return Unauthorized(); + + var result = + await nty.SubscribePushNotification(currentUser, request.Provider, currentSession.Challenge.DeviceId!, + request.DeviceToken); + + return Ok(result); + } + + [HttpDelete("subscription")] + [Authorize] + public async Task> UnsubscribeFromPushNotification() + { + HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue); + HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); + var currentUser = currentUserValue as Account; + if (currentUser == null) return Unauthorized(); + var currentSession = currentSessionValue as Session; + if (currentSession == null) return Unauthorized(); + + var affectedRows = await db.NotificationPushSubscriptions + .Where(s => + s.AccountId == currentUser.Id && + s.DeviceId == currentSession.Challenge.DeviceId + ).ExecuteDeleteAsync(); + return Ok(affectedRows); + } + + public class NotificationRequest + { + [Required] [MaxLength(1024)] public string Topic { get; set; } = null!; + [Required] [MaxLength(1024)] public string Title { get; set; } = null!; + [MaxLength(2048)] public string? Subtitle { get; set; } + [Required] [MaxLength(4096)] public string Content { get; set; } = null!; + public Dictionary? Meta { get; set; } + public int Priority { get; set; } = 10; + } + + [HttpPost("broadcast")] + [Authorize] + [RequiredPermission("global", "notifications.broadcast")] + public async Task 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 AccountId { get; set; } = null!; + } + + [HttpPost("send")] + [Authorize] + [RequiredPermission("global", "notifications.send")] + public async Task 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(); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/NotificationService.cs b/DysonNetwork.Pass/Account/NotificationService.cs new file mode 100644 index 0000000..7fd0099 --- /dev/null +++ b/DysonNetwork.Pass/Account/NotificationService.cs @@ -0,0 +1,308 @@ +using System.Text; +using System.Text.Json; +using DysonNetwork.Pass; +using EFCore.BulkExtensions; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace DysonNetwork.Pass.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 SubscribePushNotification( + Account account, + NotificationPushProvider provider, + string deviceId, + string deviceToken + ) + { + var now = SystemClock.Instance.GetCurrentInstant(); + + // First check if a matching subscription exists + var existingSubscription = await db.NotificationPushSubscriptions + .Where(s => s.AccountId == account.Id) + .Where(s => s.DeviceId == deviceId || s.DeviceToken == deviceToken) + .FirstOrDefaultAsync(); + + if (existingSubscription is not null) + { + // Update the existing subscription directly in the database + await db.NotificationPushSubscriptions + .Where(s => s.Id == existingSubscription.Id) + .ExecuteUpdateAsync(setters => setters + .SetProperty(s => s.DeviceId, deviceId) + .SetProperty(s => s.DeviceToken, deviceToken) + .SetProperty(s => s.UpdatedAt, now)); + + // Return the updated subscription + existingSubscription.DeviceId = deviceId; + existingSubscription.DeviceToken = deviceToken; + existingSubscription.UpdatedAt = now; + return existingSubscription; + } + + var subscription = new NotificationPushSubscription + { + DeviceId = deviceId, + DeviceToken = deviceToken, + Provider = provider, + AccountId = account.Id, + }; + + db.NotificationPushSubscriptions.Add(subscription); + await db.SaveChangesAsync(); + + return subscription; + } + + public async Task SendNotification( + Account account, + string topic, + string? title = null, + string? subtitle = null, + string? content = null, + Dictionary? 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(); + 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 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 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> _BuildNotificationPayload(Notification notification, + IEnumerable 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 _BuildNotificationPayload(Notification notification, int platformCode, + IEnumerable deviceTokens) + { + var alertDict = new Dictionary(); + var dict = new Dictionary + { + ["notif_id"] = notification.Id.ToString(), + ["apns_id"] = notification.Id.ToString(), + ["topic"] = _notifyTopic, + ["tokens"] = deviceTokens, + ["data"] = new Dictionary + { + ["type"] = notification.Topic, + ["meta"] = notification.Meta ?? new Dictionary(), + }, + ["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 subscriptions) + { + var subList = subscriptions.ToList(); + if (subList.Count == 0) return; + + var requestDict = new Dictionary + { + ["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(); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/Relationship.cs b/DysonNetwork.Pass/Account/Relationship.cs new file mode 100644 index 0000000..7b4aedd --- /dev/null +++ b/DysonNetwork.Pass/Account/Relationship.cs @@ -0,0 +1,22 @@ +using NodaTime; + +namespace DysonNetwork.Pass.Account; + +public enum RelationshipStatus : short +{ + Friends = 100, + Pending = 0, + Blocked = -100 +} + +public class Relationship : ModelBase +{ + public Guid AccountId { get; set; } + public Account Account { get; set; } = null!; + public Guid RelatedId { get; set; } + public Account Related { get; set; } = null!; + + public Instant? ExpiredAt { get; set; } + + public RelationshipStatus Status { get; set; } = RelationshipStatus.Pending; +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/RelationshipController.cs b/DysonNetwork.Pass/Account/RelationshipController.cs new file mode 100644 index 0000000..4f0729d --- /dev/null +++ b/DysonNetwork.Pass/Account/RelationshipController.cs @@ -0,0 +1,253 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace DysonNetwork.Pass.Account; + +[ApiController] +[Route("/api/relationships")] +public class RelationshipController(AppDatabase db, RelationshipService rels) : ControllerBase +{ + [HttpGet] + [Authorize] + public async Task>> ListRelationships([FromQuery] int offset = 0, + [FromQuery] int take = 20) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + var userId = currentUser.Id; + + var query = db.AccountRelationships.AsQueryable() + .Where(r => r.RelatedId == userId); + var totalCount = await query.CountAsync(); + var relationships = await query + .Include(r => r.Related) + .Include(r => r.Related.Profile) + .Include(r => r.Account) + .Include(r => r.Account.Profile) + .Skip(offset) + .Take(take) + .ToListAsync(); + + var statuses = await db.AccountRelationships + .Where(r => r.AccountId == userId) + .ToDictionaryAsync(r => r.RelatedId); + foreach (var relationship in relationships) + if (statuses.TryGetValue(relationship.RelatedId, out var status)) + relationship.Status = status.Status; + + Response.Headers["X-Total"] = totalCount.ToString(); + + return relationships; + } + + [HttpGet("requests")] + [Authorize] + public async Task>> ListSentRequests() + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var relationships = await db.AccountRelationships + .Where(r => r.AccountId == currentUser.Id && r.Status == RelationshipStatus.Pending) + .Include(r => r.Related) + .Include(r => r.Related.Profile) + .Include(r => r.Account) + .Include(r => r.Account.Profile) + .ToListAsync(); + + return relationships; + } + + public class RelationshipRequest + { + [Required] public RelationshipStatus Status { get; set; } + } + + [HttpPost("{userId:guid}")] + [Authorize] + public async Task> CreateRelationship(Guid userId, + [FromBody] RelationshipRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var relatedUser = await db.Accounts.FindAsync(userId); + if (relatedUser is null) return NotFound("Account was not found."); + + try + { + var relationship = await rels.CreateRelationship( + currentUser, relatedUser, request.Status + ); + return relationship; + } + catch (InvalidOperationException err) + { + return BadRequest(err.Message); + } + } + + [HttpPatch("{userId:guid}")] + [Authorize] + public async Task> UpdateRelationship(Guid userId, + [FromBody] RelationshipRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + try + { + var relationship = await rels.UpdateRelationship(currentUser.Id, userId, request.Status); + return relationship; + } + catch (ArgumentException err) + { + return NotFound(err.Message); + } + catch (InvalidOperationException err) + { + return BadRequest(err.Message); + } + } + + [HttpGet("{userId:guid}")] + [Authorize] + public async Task> GetRelationship(Guid userId) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var now = Instant.FromDateTimeUtc(DateTime.UtcNow); + var queries = db.AccountRelationships.AsQueryable() + .Where(r => r.AccountId == currentUser.Id && r.RelatedId == userId) + .Where(r => r.ExpiredAt == null || r.ExpiredAt > now); + var relationship = await queries + .Include(r => r.Related) + .Include(r => r.Related.Profile) + .FirstOrDefaultAsync(); + if (relationship is null) return NotFound(); + + relationship.Account = currentUser; + return Ok(relationship); + } + + [HttpPost("{userId:guid}/friends")] + [Authorize] + public async Task> SendFriendRequest(Guid userId) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var relatedUser = await db.Accounts.FindAsync(userId); + if (relatedUser is null) return NotFound("Account was not found."); + + var existing = await db.AccountRelationships.FirstOrDefaultAsync(r => + (r.AccountId == currentUser.Id && r.RelatedId == userId) || + (r.AccountId == userId && r.RelatedId == currentUser.Id)); + if (existing != null) return BadRequest("Relationship already exists."); + + try + { + var relationship = await rels.SendFriendRequest(currentUser, relatedUser); + return relationship; + } + catch (InvalidOperationException err) + { + return BadRequest(err.Message); + } + } + + [HttpDelete("{userId:guid}/friends")] + [Authorize] + public async Task DeleteFriendRequest(Guid userId) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + try + { + await rels.DeleteFriendRequest(currentUser.Id, userId); + return NoContent(); + } + catch (ArgumentException err) + { + return NotFound(err.Message); + } + } + + [HttpPost("{userId:guid}/friends/accept")] + [Authorize] + public async Task> AcceptFriendRequest(Guid userId) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var relationship = await rels.GetRelationship(userId, currentUser.Id, RelationshipStatus.Pending); + if (relationship is null) return NotFound("Friend request was not found."); + + try + { + relationship = await rels.AcceptFriendRelationship(relationship); + return relationship; + } + catch (InvalidOperationException err) + { + return BadRequest(err.Message); + } + } + + [HttpPost("{userId:guid}/friends/decline")] + [Authorize] + public async Task> DeclineFriendRequest(Guid userId) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var relationship = await rels.GetRelationship(userId, currentUser.Id, RelationshipStatus.Pending); + if (relationship is null) return NotFound("Friend request was not found."); + + try + { + relationship = await rels.AcceptFriendRelationship(relationship, status: RelationshipStatus.Blocked); + return relationship; + } + catch (InvalidOperationException err) + { + return BadRequest(err.Message); + } + } + + [HttpPost("{userId:guid}/block")] + [Authorize] + public async Task> BlockUser(Guid userId) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var relatedUser = await db.Accounts.FindAsync(userId); + if (relatedUser is null) return NotFound("Account was not found."); + + try + { + var relationship = await rels.BlockAccount(currentUser, relatedUser); + return relationship; + } + catch (InvalidOperationException err) + { + return BadRequest(err.Message); + } + } + + [HttpDelete("{userId:guid}/block")] + [Authorize] + public async Task> UnblockUser(Guid userId) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + + var relatedUser = await db.Accounts.FindAsync(userId); + if (relatedUser is null) return NotFound("Account was not found."); + + try + { + var relationship = await rels.UnblockAccount(currentUser, relatedUser); + return relationship; + } + catch (InvalidOperationException err) + { + return BadRequest(err.Message); + } + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/RelationshipService.cs b/DysonNetwork.Pass/Account/RelationshipService.cs new file mode 100644 index 0000000..20be861 --- /dev/null +++ b/DysonNetwork.Pass/Account/RelationshipService.cs @@ -0,0 +1,207 @@ +using DysonNetwork.Shared.Cache; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace DysonNetwork.Pass.Account; + +public class RelationshipService(AppDatabase db, ICacheService cache) +{ + private const string UserFriendsCacheKeyPrefix = "accounts:friends:"; + private const string UserBlockedCacheKeyPrefix = "accounts:blocked:"; + + public async Task 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 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 CreateRelationship(Account sender, Account target, RelationshipStatus status) + { + if (status == RelationshipStatus.Pending) + throw new InvalidOperationException( + "Cannot create relationship with pending status, use SendFriendRequest instead."); + if (await HasExistingRelationship(sender.Id, target.Id)) + throw new InvalidOperationException("Found existing relationship between you and target user."); + + var relationship = new Relationship + { + AccountId = sender.Id, + RelatedId = target.Id, + Status = status + }; + + db.AccountRelationships.Add(relationship); + await db.SaveChangesAsync(); + + await PurgeRelationshipCache(sender.Id, target.Id); + + return relationship; + } + + public async Task BlockAccount(Account sender, Account target) + { + if (await HasExistingRelationship(sender.Id, target.Id)) + return await UpdateRelationship(sender.Id, target.Id, RelationshipStatus.Blocked); + return await CreateRelationship(sender, target, RelationshipStatus.Blocked); + } + + public async Task UnblockAccount(Account sender, Account target) + { + var relationship = await GetRelationship(sender.Id, target.Id, RelationshipStatus.Blocked); + if (relationship is null) throw new ArgumentException("There is no relationship between you and the user."); + db.Remove(relationship); + await db.SaveChangesAsync(); + + await PurgeRelationshipCache(sender.Id, target.Id); + + return relationship; + } + + public async Task SendFriendRequest(Account sender, Account target) + { + if (await HasExistingRelationship(sender.Id, target.Id)) + throw new InvalidOperationException("Found existing relationship between you and target user."); + + var relationship = new Relationship + { + AccountId = sender.Id, + RelatedId = target.Id, + Status = RelationshipStatus.Pending, + ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(7)) + }; + + db.AccountRelationships.Add(relationship); + await db.SaveChangesAsync(); + + return relationship; + } + + public async Task DeleteFriendRequest(Guid accountId, Guid relatedId) + { + var relationship = await GetRelationship(accountId, relatedId, RelationshipStatus.Pending); + if (relationship is null) throw new ArgumentException("Friend request was not found."); + + await db.AccountRelationships + .Where(r => r.AccountId == accountId && r.RelatedId == relatedId && r.Status == RelationshipStatus.Pending) + .ExecuteDeleteAsync(); + + await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId); + } + + public async Task 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 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> ListAccountFriends(Account account) + { + var cacheKey = $"{UserFriendsCacheKeyPrefix}{account.Id}"; + var friends = await cache.GetAsync>(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> ListAccountBlocked(Account account) + { + var cacheKey = $"{UserBlockedCacheKeyPrefix}{account.Id}"; + var blocked = await cache.GetAsync>(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 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}"); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/VerificationMark.cs b/DysonNetwork.Pass/Account/VerificationMark.cs new file mode 100644 index 0000000..fc6a419 --- /dev/null +++ b/DysonNetwork.Pass/Account/VerificationMark.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace DysonNetwork.Pass.Account; + +/// +/// The verification info of a resource +/// stands, for it is really an individual or organization or a company in the real world. +/// Besides, it can also be use for mark parody or fake. +/// +public class VerificationMark +{ + public VerificationMarkType Type { get; set; } + [MaxLength(1024)] public string? Title { get; set; } + [MaxLength(8192)] public string? Description { get; set; } + [MaxLength(1024)] public string? VerifiedBy { get; set; } +} + +public enum VerificationMarkType +{ + Official, + Individual, + Organization, + Government, + Creator +} \ No newline at end of file diff --git a/DysonNetwork.Pass/AppDatabase.cs b/DysonNetwork.Pass/AppDatabase.cs new file mode 100644 index 0000000..424af99 --- /dev/null +++ b/DysonNetwork.Pass/AppDatabase.cs @@ -0,0 +1,275 @@ +using System.Linq.Expressions; +using System.Reflection; +using DysonNetwork.Pass.Account; +using DysonNetwork.Pass.Auth; +using DysonNetwork.Pass.Permission; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Query; +using NodaTime; +using Quartz; + +namespace DysonNetwork.Pass; + +public interface IIdentifiedResource +{ + public string ResourceIdentifier { get; } +} + +public abstract class ModelBase +{ + public Instant CreatedAt { get; set; } + public Instant UpdatedAt { get; set; } + public Instant? DeletedAt { get; set; } +} + +public class AppDatabase( + DbContextOptions options, + IConfiguration configuration +) : DbContext(options) +{ + public DbSet PermissionNodes { get; set; } + public DbSet PermissionGroups { get; set; } + public DbSet PermissionGroupMembers { get; set; } + + public DbSet MagicSpells { get; set; } + public DbSet Accounts { get; set; } + public DbSet AccountConnections { get; set; } + public DbSet AccountProfiles { get; set; } + public DbSet AccountContacts { get; set; } + public DbSet AccountAuthFactors { get; set; } + public DbSet AccountRelationships { get; set; } + public DbSet AccountStatuses { get; set; } + public DbSet AccountCheckInResults { get; set; } + public DbSet Notifications { get; set; } + public DbSet NotificationPushSubscriptions { get; set; } + public DbSet Badges { get; set; } + public DbSet ActionLogs { get; set; } + public DbSet AbuseReports { get; set; } + + public DbSet AuthSessions { get; set; } + public DbSet AuthChallenges { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseNpgsql( + configuration.GetConnectionString("App"), + opt => opt + .ConfigureDataSource(optSource => optSource.EnableDynamicJson()) + .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery) + .UseNetTopologySuite() + .UseNodaTime() + ).UseSnakeCaseNamingConvention(); + + optionsBuilder.UseAsyncSeeding(async (context, _, cancellationToken) => + { + var defaultPermissionGroup = await context.Set() + .FirstOrDefaultAsync(g => g.Key == "default", cancellationToken); + if (defaultPermissionGroup is null) + { + context.Set().Add(new PermissionGroup + { + Key = "default", + Nodes = new List + { + "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); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity() + .HasKey(pg => new { pg.GroupId, pg.Actor }); + modelBuilder.Entity() + .HasOne(pg => pg.Group) + .WithMany(g => g.Members) + .HasForeignKey(pg => pg.GroupId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasKey(r => new { FromAccountId = r.AccountId, ToAccountId = r.RelatedId }); + modelBuilder.Entity() + .HasOne(r => r.Account) + .WithMany(a => a.OutgoingRelationships) + .HasForeignKey(r => r.AccountId); + modelBuilder.Entity() + .HasOne(r => r.Related) + .WithMany(a => a.IncomingRelationships) + .HasForeignKey(r => r.RelatedId); + + // Automatically apply soft-delete filter to all entities inheriting BaseModel + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + if (!typeof(ModelBase).IsAssignableFrom(entityType.ClrType)) continue; + var method = typeof(AppDatabase) + .GetMethod(nameof(SetSoftDeleteFilter), + BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(entityType.ClrType); + + method.Invoke(null, [modelBuilder]); + } + } + + private static void SetSoftDeleteFilter(ModelBuilder modelBuilder) + where TEntity : ModelBase + { + modelBuilder.Entity().HasQueryFilter(e => e.DeletedAt == null); + } + + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + var now = SystemClock.Instance.GetCurrentInstant(); + + foreach (var entry in ChangeTracker.Entries()) + { + switch (entry.State) + { + case EntityState.Added: + entry.Entity.CreatedAt = now; + entry.Entity.UpdatedAt = now; + break; + case EntityState.Modified: + entry.Entity.UpdatedAt = now; + break; + case EntityState.Deleted: + entry.State = EntityState.Modified; + entry.Entity.DeletedAt = now; + break; + case EntityState.Detached: + case EntityState.Unchanged: + default: + break; + } + } + + return await base.SaveChangesAsync(cancellationToken); + } +} + +public class AppDatabaseRecyclingJob(AppDatabase db, ILogger logger) : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + var now = SystemClock.Instance.GetCurrentInstant(); + + logger.LogInformation("Cleaning up expired records..."); + + // Expired relationships + var affectedRows = await db.AccountRelationships + .Where(x => x.ExpiredAt != null && x.ExpiredAt <= now) + .ExecuteDeleteAsync(); + logger.LogDebug("Removed {Count} records of expired relationships.", affectedRows); + // Expired permission group members + affectedRows = await db.PermissionGroupMembers + .Where(x => x.ExpiredAt != null && x.ExpiredAt <= now) + .ExecuteDeleteAsync(); + logger.LogDebug("Removed {Count} records of expired permission group members.", affectedRows); + + logger.LogInformation("Deleting soft-deleted records..."); + + var threshold = now - Duration.FromDays(7); + + var entityTypes = db.Model.GetEntityTypes() + .Where(t => typeof(ModelBase).IsAssignableFrom(t.ClrType) && t.ClrType != typeof(ModelBase)) + .Select(t => t.ClrType); + + foreach (var entityType in entityTypes) + { + var set = (IQueryable)db.GetType().GetMethod(nameof(DbContext.Set), Type.EmptyTypes)! + .MakeGenericMethod(entityType).Invoke(db, null)!; + var parameter = Expression.Parameter(entityType, "e"); + var property = Expression.Property(parameter, nameof(ModelBase.DeletedAt)); + var condition = Expression.LessThan(property, Expression.Constant(threshold, typeof(Instant?))); + var notNull = Expression.NotEqual(property, Expression.Constant(null, typeof(Instant?))); + var finalCondition = Expression.AndAlso(notNull, condition); + var lambda = Expression.Lambda(finalCondition, parameter); + + var queryable = set.Provider.CreateQuery( + Expression.Call( + typeof(Queryable), + "Where", + [entityType], + set.Expression, + Expression.Quote(lambda) + ) + ); + + var toListAsync = typeof(EntityFrameworkQueryableExtensions) + .GetMethod(nameof(EntityFrameworkQueryableExtensions.ToListAsync))! + .MakeGenericMethod(entityType); + + var items = await (dynamic)toListAsync.Invoke(null, [queryable, CancellationToken.None])!; + db.RemoveRange(items); + } + + await db.SaveChangesAsync(); + } +} + +public class AppDatabaseFactory : IDesignTimeDbContextFactory +{ + public AppDatabase CreateDbContext(string[] args) + { + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json") + .Build(); + + var optionsBuilder = new DbContextOptionsBuilder(); + return new AppDatabase(optionsBuilder.Options, configuration); + } +} + +public static class OptionalQueryExtensions +{ + public static IQueryable If( + this IQueryable source, + bool condition, + Func, IQueryable> transform + ) + { + return condition ? transform(source) : source; + } + + public static IQueryable If( + this IIncludableQueryable source, + bool condition, + Func, IQueryable> transform + ) + where T : class + { + return condition ? transform(source) : source; + } + + public static IQueryable If( + this IIncludableQueryable> source, + bool condition, + Func>, IQueryable> transform + ) + where T : class + { + return condition ? transform(source) : source; + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/Auth.cs b/DysonNetwork.Pass/Auth/Auth.cs new file mode 100644 index 0000000..fd8632a --- /dev/null +++ b/DysonNetwork.Pass/Auth/Auth.cs @@ -0,0 +1,273 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text.Encodings.Web; +using DysonNetwork.Pass.Account; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using DysonNetwork.Pass.Auth.OidcProvider.Services; +using DysonNetwork.Pass.Handlers; +using DysonNetwork.Shared.Cache; +using SystemClock = NodaTime.SystemClock; + +namespace DysonNetwork.Pass.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 options, + IConfiguration configuration, + ILoggerFactory logger, + UrlEncoder encoder, + AppDatabase database, + OidcProviderService oidc, + ICacheService cache, + FlushBufferService fbs +) + : AuthenticationHandler(options, logger, encoder) +{ + public const string AuthCachePrefix = "auth:"; + + protected override async Task 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($"{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 + { + 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; + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/AuthController.cs b/DysonNetwork.Pass/Auth/AuthController.cs new file mode 100644 index 0000000..517da48 --- /dev/null +++ b/DysonNetwork.Pass/Auth/AuthController.cs @@ -0,0 +1,266 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc; +using NodaTime; +using Microsoft.EntityFrameworkCore; +using DysonNetwork.Pass.Account; +using DysonNetwork.Shared.Geo; + +namespace DysonNetwork.Pass.Auth; + +[ApiController] +[Route("/api/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 Audiences { get; set; } = new(); + public List Scopes { get; set; } = new(); + } + + [HttpPost("challenge")] + public async Task> 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 { { "challenge_id", challenge.Id } }, Request, account + ); + + return challenge; + } + + [HttpGet("challenge/{id:guid}")] + public async Task> 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>> 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 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> 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 + { + { "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 + { + { "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 + { + { "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> 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 ValidateCaptcha([FromBody] string token) + { + var result = await auth.ValidateCaptcha(token); + return result ? Ok() : BadRequest(); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/AuthService.cs b/DysonNetwork.Pass/Auth/AuthService.cs new file mode 100644 index 0000000..7488b3c --- /dev/null +++ b/DysonNetwork.Pass/Auth/AuthService.cs @@ -0,0 +1,304 @@ +using System.Security.Cryptography; +using System.Text.Json; +using DysonNetwork.Pass.Account; +using DysonNetwork.Shared.Cache; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace DysonNetwork.Pass.Auth; + +public class AuthService( + AppDatabase db, + IConfiguration config, + IHttpClientFactory httpClientFactory, + IHttpContextAccessor httpContextAccessor, + ICacheService cache +) +{ + private HttpContext HttpContext => httpContextAccessor.HttpContext!; + + /// + /// Detect the risk of the current request to login + /// and returns the required steps to login. + /// + /// The request context + /// The account to login + /// The required steps to login + public async Task DetectChallengeRisk(HttpRequest request, Account.Account account) + { + // 1) Find out how many authentication factors the account has enabled. + var maxSteps = await db.AccountAuthFactors + .Where(f => f.AccountId == account.Id) + .Where(f => f.EnabledAt != null) + .CountAsync(); + + // 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 CreateSessionForOidcAsync(Account.Account account, Instant time, Guid? customAppId = null) + { + var challenge = new Challenge + { + AccountId = account.Id, + IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(), + UserAgent = HttpContext.Request.Headers.UserAgent, + StepRemain = 1, + StepTotal = 1, + Type = customAppId is not null ? ChallengeType.OAuth : ChallengeType.Oidc + }; + + var session = new Session + { + AccountId = account.Id, + CreatedAt = time, + LastGrantedAt = time, + Challenge = challenge, + AppId = customAppId + }; + + db.AuthChallenges.Add(challenge); + db.AuthSessions.Add(session); + await db.SaveChangesAsync(); + + return session; + } + + public async Task 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(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(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(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 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(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 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); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/CheckpointModel.cs b/DysonNetwork.Pass/Auth/CheckpointModel.cs new file mode 100644 index 0000000..58466e9 --- /dev/null +++ b/DysonNetwork.Pass/Auth/CheckpointModel.cs @@ -0,0 +1,6 @@ +namespace DysonNetwork.Pass.Auth; + +public class CaptchaVerificationResponse +{ + public bool Success { get; set; } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/CompactTokenService.cs b/DysonNetwork.Pass/Auth/CompactTokenService.cs new file mode 100644 index 0000000..3ef9742 --- /dev/null +++ b/DysonNetwork.Pass/Auth/CompactTokenService.cs @@ -0,0 +1,94 @@ +using System.Security.Cryptography; + +namespace DysonNetwork.Pass.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); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/OidcProvider/Controllers/OidcProviderController.cs b/DysonNetwork.Pass/Auth/OidcProvider/Controllers/OidcProviderController.cs new file mode 100644 index 0000000..c033b93 --- /dev/null +++ b/DysonNetwork.Pass/Auth/OidcProvider/Controllers/OidcProviderController.cs @@ -0,0 +1,241 @@ +using System.Security.Cryptography; +using DysonNetwork.Pass.Auth.OidcProvider.Responses; +using DysonNetwork.Pass.Auth.OidcProvider.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using System.Text.Json.Serialization; +using DysonNetwork.Pass.Account; +using DysonNetwork.Pass.Auth.OidcProvider.Options; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using NodaTime; + +namespace DysonNetwork.Pass.Auth.OidcProvider.Controllers; + +[Route("/api/auth/open")] +[ApiController] +public class OidcProviderController( + AppDatabase db, + OidcProviderService oidcService, + IConfiguration configuration, + IOptions options, + ILogger logger +) + : ControllerBase +{ + [HttpPost("token")] + [Consumes("application/x-www-form-urlencoded")] + public async Task 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 GetUserInfo() + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser || + HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized(); + + // Get requested scopes from the token + var scopes = currentSession.Challenge.Scopes; + + var userInfo = new Dictionary + { + ["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; } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/OidcProvider/Models/AuthorizationCodeInfo.cs b/DysonNetwork.Pass/Auth/OidcProvider/Models/AuthorizationCodeInfo.cs new file mode 100644 index 0000000..4f8ca5d --- /dev/null +++ b/DysonNetwork.Pass/Auth/OidcProvider/Models/AuthorizationCodeInfo.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using NodaTime; + +namespace DysonNetwork.Pass.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 Scopes { get; set; } = new(); + public string? CodeChallenge { get; set; } + public string? CodeChallengeMethod { get; set; } + public string? Nonce { get; set; } + public Instant CreatedAt { get; set; } +} diff --git a/DysonNetwork.Pass/Auth/OidcProvider/Options/OidcProviderOptions.cs b/DysonNetwork.Pass/Auth/OidcProvider/Options/OidcProviderOptions.cs new file mode 100644 index 0000000..40397a5 --- /dev/null +++ b/DysonNetwork.Pass/Auth/OidcProvider/Options/OidcProviderOptions.cs @@ -0,0 +1,36 @@ +using System.Security.Cryptography; + +namespace DysonNetwork.Pass.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; + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/OidcProvider/Responses/AuthorizationResponse.cs b/DysonNetwork.Pass/Auth/OidcProvider/Responses/AuthorizationResponse.cs new file mode 100644 index 0000000..407520b --- /dev/null +++ b/DysonNetwork.Pass/Auth/OidcProvider/Responses/AuthorizationResponse.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; + +namespace DysonNetwork.Pass.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; } +} diff --git a/DysonNetwork.Pass/Auth/OidcProvider/Responses/ErrorResponse.cs b/DysonNetwork.Pass/Auth/OidcProvider/Responses/ErrorResponse.cs new file mode 100644 index 0000000..b72a4a7 --- /dev/null +++ b/DysonNetwork.Pass/Auth/OidcProvider/Responses/ErrorResponse.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace DysonNetwork.Pass.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; } +} diff --git a/DysonNetwork.Pass/Auth/OidcProvider/Responses/TokenResponse.cs b/DysonNetwork.Pass/Auth/OidcProvider/Responses/TokenResponse.cs new file mode 100644 index 0000000..3c7e50c --- /dev/null +++ b/DysonNetwork.Pass/Auth/OidcProvider/Responses/TokenResponse.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +namespace DysonNetwork.Pass.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; } +} diff --git a/DysonNetwork.Pass/Auth/OidcProvider/Services/OidcProviderService.cs b/DysonNetwork.Pass/Auth/OidcProvider/Services/OidcProviderService.cs new file mode 100644 index 0000000..a4d5968 --- /dev/null +++ b/DysonNetwork.Pass/Auth/OidcProvider/Services/OidcProviderService.cs @@ -0,0 +1,394 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using DysonNetwork.Pass.Auth.OidcProvider.Models; +using DysonNetwork.Pass.Auth.OidcProvider.Options; +using DysonNetwork.Pass.Auth.OidcProvider.Responses; +using DysonNetwork.Shared.Cache; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using NodaTime; + +namespace DysonNetwork.Pass.Auth.OidcProvider.Services; + +public class OidcProviderService( + AppDatabase db, + AuthService auth, + ICacheService cache, + IOptions options, + ILogger logger +) +{ + private readonly OidcProviderOptions _options = options.Value; + + public async Task FindClientByIdAsync(Guid clientId) + { + return await db.CustomApps + .Include(c => c.Secrets) + .FirstOrDefaultAsync(c => c.Id == clientId); + } + + public async Task FindClientByAppIdAsync(Guid appId) + { + return await db.CustomApps + .Include(c => c.Secrets) + .FirstOrDefaultAsync(c => c.Id == appId); + } + + public async Task 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 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 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? 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? 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 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 GenerateAuthorizationCodeForReuseSessionAsync( + Session session, + Guid clientId, + string redirectUri, + IEnumerable 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 GenerateAuthorizationCodeAsync( + Guid clientId, + Guid userId, + string redirectUri, + IEnumerable 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 ValidateAuthorizationCodeAsync( + string code, + Guid clientId, + string? redirectUri = null, + string? codeVerifier = null + ) + { + var cacheKey = $"auth:code:{code}"; + var (found, authCode) = await cache.GetAsyncWithStatus(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; + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/OpenId/AfdianOidcService.cs b/DysonNetwork.Pass/Auth/OpenId/AfdianOidcService.cs new file mode 100644 index 0000000..d55f090 --- /dev/null +++ b/DysonNetwork.Pass/Auth/OpenId/AfdianOidcService.cs @@ -0,0 +1,95 @@ +using System.Net.Http.Json; +using System.Text.Json; +using DysonNetwork.Pass; +using DysonNetwork.Shared.Cache; + +namespace DysonNetwork.Pass.Auth.OpenId; + +public class AfdianOidcService( + IConfiguration configuration, + IHttpClientFactory httpClientFactory, + AppDatabase db, + AuthService auth, + ICacheService cache, + ILogger 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 + { + { "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 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 ProcessCallbackAsync(OidcCallbackData callbackData) + { + try + { + var config = GetProviderConfig(); + var content = new FormUrlEncodedContent(new Dictionary + { + { "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; + } + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/OpenId/AppleMobileSignInRequest.cs b/DysonNetwork.Pass/Auth/OpenId/AppleMobileSignInRequest.cs new file mode 100644 index 0000000..c655a88 --- /dev/null +++ b/DysonNetwork.Pass/Auth/OpenId/AppleMobileSignInRequest.cs @@ -0,0 +1,19 @@ + +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace DysonNetwork.Pass.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; } +} diff --git a/DysonNetwork.Pass/Auth/OpenId/AppleOidcService.cs b/DysonNetwork.Pass/Auth/OpenId/AppleOidcService.cs new file mode 100644 index 0000000..67e4b21 --- /dev/null +++ b/DysonNetwork.Pass/Auth/OpenId/AppleOidcService.cs @@ -0,0 +1,280 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using DysonNetwork.Pass; +using DysonNetwork.Shared.Cache; +using Microsoft.IdentityModel.Tokens; + +namespace DysonNetwork.Pass.Auth.OpenId; + +/// +/// Implementation of OpenID Connect service for Apple Sign In +/// +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 + { + { "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 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(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 ValidateTokenAsync(string idToken) + { + // Get Apple's public keys + var jwksJson = await GetAppleJwksAsync(); + var jwks = JsonSerializer.Deserialize(jwksJson) ?? new AppleJwks { Keys = new List() }; + + // 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 BuildTokenRequestParameters( + string code, + ProviderConfiguration config, + string? codeVerifier + ) + { + var parameters = new Dictionary + { + { "client_id", config.ClientId }, + { "client_secret", GenerateClientSecret() }, + { "code", code }, + { "grant_type", "authorization_code" }, + { "redirect_uri", config.RedirectUri } + }; + + return parameters; + } + + private async Task GetAppleJwksAsync() + { + var client = _httpClientFactory.CreateClient(); + var response = await client.GetAsync("https://appleid.apple.com/auth/keys"); + response.EnsureSuccessStatusCode(); + + return await response.Content.ReadAsStringAsync(); + } + + /// + /// Generates a client secret for Apple Sign In using JWT + /// + 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 + { + { "alg", "ES256" }, + { "kid", keyId } + }; + + // Create the JWT payload + var payload = new Dictionary + { + { "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 Keys { get; set; } = new List(); +} + +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); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/OpenId/ConnectionController.cs b/DysonNetwork.Pass/Auth/OpenId/ConnectionController.cs new file mode 100644 index 0000000..2f5dadb --- /dev/null +++ b/DysonNetwork.Pass/Auth/OpenId/ConnectionController.cs @@ -0,0 +1,409 @@ +using DysonNetwork.Pass.Account; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DysonNetwork.Shared.Cache; +using NodaTime; + +namespace DysonNetwork.Pass.Auth.OpenId; + +[ApiController] +[Route("/api/accounts/me/connections")] +[Authorize] +public class ConnectionController( + AppDatabase db, + IEnumerable 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>> GetConnections() + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) + return Unauthorized(); + + var connections = await db.AccountConnections + .Where(c => c.AccountId == currentUser.Id) + .Select(c => new + { + c.Id, + c.AccountId, + c.Provider, + c.ProvidedIdentifier, + c.Meta, + c.LastUsedAt, + c.CreatedAt, + c.UpdatedAt, + }) + .ToListAsync(); + return Ok(connections); + } + + [HttpDelete("{id:guid}")] + public async Task RemoveConnection(Guid id) + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) + return Unauthorized(); + + var connection = await db.AccountConnections + .Where(c => c.Id == id && c.AccountId == currentUser.Id) + .FirstOrDefaultAsync(); + if (connection == null) + return NotFound(); + + db.AccountConnections.Remove(connection); + await db.SaveChangesAsync(); + + return Ok(); + } + + [HttpPost("/auth/connect/apple/mobile")] + public async Task ConnectAppleMobile([FromBody] AppleMobileConnectRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) + return Unauthorized(); + + if (GetOidcService("apple") is not AppleOidcService appleService) + return StatusCode(503, "Apple OIDC service not available"); + + var callbackData = new OidcCallbackData + { + IdToken = request.IdentityToken, + Code = request.AuthorizationCode, + }; + + OidcUserInfo userInfo; + try + { + userInfo = await appleService.ProcessCallbackAsync(callbackData); + } + catch (Exception ex) + { + return BadRequest($"Error processing Apple token: {ex.Message}"); + } + + var existingConnection = await db.AccountConnections + .FirstOrDefaultAsync(c => + c.Provider == "apple" && + c.ProvidedIdentifier == userInfo.UserId); + + if (existingConnection != null) + { + return BadRequest( + $"This Apple account is already linked to {(existingConnection.AccountId == currentUser.Id ? "your account" : "another user")}."); + } + + db.AccountConnections.Add(new AccountConnection + { + AccountId = currentUser.Id, + Provider = "apple", + ProvidedIdentifier = userInfo.UserId!, + AccessToken = userInfo.AccessToken, + RefreshToken = userInfo.RefreshToken, + LastUsedAt = SystemClock.Instance.GetCurrentInstant(), + Meta = userInfo.ToMetadata(), + }); + + await db.SaveChangesAsync(); + + return Ok(new { message = "Successfully connected Apple account." }); + } + + private OidcService? GetOidcService(string provider) + { + return oidcServices.FirstOrDefault(s => s.ProviderName.Equals(provider, StringComparison.OrdinalIgnoreCase)); + } + + public class ConnectProviderRequest + { + public string Provider { get; set; } = null!; + public string? ReturnUrl { get; set; } + } + + /// + /// Initiates manual connection to an OAuth provider for the current user + /// + [HttpPost("connect")] + public async Task> InitiateConnection([FromBody] ConnectProviderRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) + return Unauthorized(); + + var oidcService = GetOidcService(request.Provider); + if (oidcService == null) + return BadRequest($"Provider '{request.Provider}' is not supported"); + + var existingConnection = await db.AccountConnections + .AnyAsync(c => c.AccountId == currentUser.Id && c.Provider == oidcService.ProviderName); + + if (existingConnection) + return BadRequest($"You already have a {request.Provider} connection"); + + var state = Guid.NewGuid().ToString("N"); + var nonce = Guid.NewGuid().ToString("N"); + var stateValue = $"{currentUser.Id}|{request.Provider}|{nonce}"; + var finalReturnUrl = !string.IsNullOrEmpty(request.ReturnUrl) ? request.ReturnUrl : "/settings/connections"; + + // Store state and return URL in cache + await cache.SetAsync($"{StateCachePrefix}{state}", stateValue, StateExpiration); + await cache.SetAsync($"{ReturnUrlCachePrefix}{state}", finalReturnUrl, StateExpiration); + + var authUrl = oidcService.GetAuthorizationUrl(state, nonce); + + return Ok(new + { + authUrl, + message = $"Redirect to this URL to connect your {request.Provider} account" + }); + } + + [AllowAnonymous] + [Route("/api/auth/callback/{provider}")] + [HttpGet, HttpPost] + public async Task 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(stateKey); + + // If not found, try to get as string (legacy format) + if (oidcState == null) + { + var stateValue = await cache.GetAsync(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 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(returnUrlKey); + await cache.RemoveAsync(returnUrlKey); + + return Redirect(string.IsNullOrEmpty(returnUrl) ? "/auth/callback" : returnUrl); + } + + private async Task 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 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; + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/OpenId/DiscordOidcService.cs b/DysonNetwork.Pass/Auth/OpenId/DiscordOidcService.cs new file mode 100644 index 0000000..8d8cc20 --- /dev/null +++ b/DysonNetwork.Pass/Auth/OpenId/DiscordOidcService.cs @@ -0,0 +1,116 @@ +using System.Net.Http.Json; +using System.Text.Json; +using DysonNetwork.Pass; +using DysonNetwork.Shared.Cache; + +namespace DysonNetwork.Pass.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 + { + { "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 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 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 ExchangeCodeForTokensAsync(string code, + string? codeVerifier = null) + { + var config = GetProviderConfig(); + var client = HttpClientFactory.CreateClient(); + + var content = new FormUrlEncodedContent(new Dictionary + { + { "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(); + } + + private async Task 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 + }; + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/OpenId/GitHubOidcService.cs b/DysonNetwork.Pass/Auth/OpenId/GitHubOidcService.cs new file mode 100644 index 0000000..0e36906 --- /dev/null +++ b/DysonNetwork.Pass/Auth/OpenId/GitHubOidcService.cs @@ -0,0 +1,128 @@ +using System.Net.Http.Json; +using System.Text.Json; +using DysonNetwork.Pass; +using DysonNetwork.Shared.Cache; + +namespace DysonNetwork.Pass.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 + { + { "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 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 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 + { + { "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(); + } + + private async Task 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.Pass"); + + 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 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.Pass"); + + var response = await client.SendAsync(request); + if (!response.IsSuccessStatusCode) return null; + + var emails = await response.Content.ReadFromJsonAsync>(); + 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; } + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/OpenId/GoogleOidcService.cs b/DysonNetwork.Pass/Auth/OpenId/GoogleOidcService.cs new file mode 100644 index 0000000..5a1927c --- /dev/null +++ b/DysonNetwork.Pass/Auth/OpenId/GoogleOidcService.cs @@ -0,0 +1,137 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using DysonNetwork.Pass; +using DysonNetwork.Shared.Cache; +using Microsoft.IdentityModel.Tokens; + +namespace DysonNetwork.Pass.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 + { + { "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 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>(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 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(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); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/OpenId/MicrosoftOidcService.cs b/DysonNetwork.Pass/Auth/OpenId/MicrosoftOidcService.cs new file mode 100644 index 0000000..0d1b4b8 --- /dev/null +++ b/DysonNetwork.Pass/Auth/OpenId/MicrosoftOidcService.cs @@ -0,0 +1,123 @@ +using System.Text.Json; +using DysonNetwork.Shared.Cache; + +namespace DysonNetwork.Pass.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 + { + { "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 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 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 + { + { "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(); + } + + private async Task 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 + }; + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/OpenId/OidcController.cs b/DysonNetwork.Pass/Auth/OpenId/OidcController.cs new file mode 100644 index 0000000..43f5053 --- /dev/null +++ b/DysonNetwork.Pass/Auth/OpenId/OidcController.cs @@ -0,0 +1,194 @@ +using DysonNetwork.Pass.Account; +using DysonNetwork.Shared.Cache; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using NodaTime; + +namespace DysonNetwork.Pass.Auth.OpenId; + +[ApiController] +[Route("/api/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 OidcLogin( + [FromRoute] string provider, + [FromQuery] string? returnUrl = "/", + [FromHeader(Name = "X-Device-Id")] string? deviceId = null + ) + { + try + { + var oidcService = GetOidcService(provider); + + // If the user is already authenticated, treat as an account connection request + if (HttpContext.Items["CurrentUser"] is Account.Account currentUser) + { + var state = Guid.NewGuid().ToString(); + var nonce = Guid.NewGuid().ToString(); + + // Create and store connection state + var oidcState = OidcState.ForConnection(currentUser.Id, provider, nonce, deviceId); + await cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration); + + // The state parameter sent to the provider is the GUID key for the cache. + var authUrl = oidcService.GetAuthorizationUrl(state, nonce); + return Redirect(authUrl); + } + else // Otherwise, proceed with the login / registration flow + { + var nonce = Guid.NewGuid().ToString(); + var state = Guid.NewGuid().ToString(); + + // Create login state with return URL and device ID + var oidcState = OidcState.ForLogin(returnUrl ?? "/", deviceId); + await cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration); + var authUrl = oidcService.GetAuthorizationUrl(state, nonce); + return Redirect(authUrl); + } + } + catch (Exception ex) + { + return BadRequest($"Error initiating OpenID Connect flow: {ex.Message}"); + } + } + + /// + /// Mobile Apple Sign In endpoint + /// Handles Apple authentication directly from mobile apps + /// + [HttpPost("apple/mobile")] + public async Task> 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(), + "google" => serviceProvider.GetRequiredService(), + "microsoft" => serviceProvider.GetRequiredService(), + "discord" => serviceProvider.GetRequiredService(), + "github" => serviceProvider.GetRequiredService(), + "afdian" => serviceProvider.GetRequiredService(), + _ => throw new ArgumentException($"Unsupported provider: {provider}") + }; + } + + private async Task 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; + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/OpenId/OidcService.cs b/DysonNetwork.Pass/Auth/OpenId/OidcService.cs new file mode 100644 index 0000000..ea1b7df --- /dev/null +++ b/DysonNetwork.Pass/Auth/OpenId/OidcService.cs @@ -0,0 +1,294 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Text.Json.Serialization; +using DysonNetwork.Pass.Account; +using DysonNetwork.Shared.Cache; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using NodaTime; + +namespace DysonNetwork.Pass.Auth.OpenId; + +/// +/// Base service for OpenID Connect authentication providers +/// +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; + + /// + /// Gets the unique identifier for this provider + /// + public abstract string ProviderName { get; } + + /// + /// Gets the OIDC discovery document endpoint + /// + protected abstract string DiscoveryEndpoint { get; } + + /// + /// Gets configuration section name for this provider + /// + protected abstract string ConfigSectionName { get; } + + /// + /// Gets the authorization URL for initiating the authentication flow + /// + public abstract string GetAuthorizationUrl(string state, string nonce); + + /// + /// Process the callback from the OIDC provider + /// + public abstract Task ProcessCallbackAsync(OidcCallbackData callbackData); + + /// + /// Gets the provider configuration + /// + protected ProviderConfiguration GetProviderConfig() + { + return new ProviderConfiguration + { + ClientId = Configuration[$"Oidc:{ConfigSectionName}:ClientId"] ?? "", + ClientSecret = Configuration[$"Oidc:{ConfigSectionName}:ClientSecret"] ?? "", + RedirectUri = Configuration["BaseUrl"] + "/auth/callback/" + ProviderName.ToLower() + }; + } + + /// + /// Retrieves the OpenID Connect discovery document + /// + protected virtual async Task 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(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(); + + // 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; + + } + + /// + /// Exchange the authorization code for tokens + /// + protected virtual async Task 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(); + } + + /// + /// Build the token request parameters + /// + protected virtual Dictionary BuildTokenRequestParameters(string code, ProviderConfiguration config, + string? codeVerifier) + { + var parameters = new Dictionary + { + { "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; + } + + /// + /// Validates and extracts information from an ID token + /// + 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 + }; + } + + /// + /// Creates a challenge and session for an authenticated user + /// Also creates or updates the account connection + /// + public async Task CreateChallengeForUserAsync( + OidcUserInfo userInfo, + Account.Account account, + HttpContext request, + string deviceId + ) + { + // Create or update the account connection + var connection = await Db.AccountConnections + .FirstOrDefaultAsync(c => c.Provider == ProviderName && + c.ProvidedIdentifier == userInfo.UserId && + c.AccountId == account.Id + ); + + if (connection is null) + { + connection = new AccountConnection + { + Provider = ProviderName, + ProvidedIdentifier = userInfo.UserId ?? "", + AccessToken = userInfo.AccessToken, + RefreshToken = userInfo.RefreshToken, + LastUsedAt = SystemClock.Instance.GetCurrentInstant(), + AccountId = account.Id + }; + await Db.AccountConnections.AddAsync(connection); + } + + // Create a challenge that's already completed + var now = SystemClock.Instance.GetCurrentInstant(); + var challenge = new Challenge + { + ExpiredAt = now.Plus(Duration.FromHours(1)), + StepTotal = await auth.DetectChallengeRisk(request.Request, account), + Type = ChallengeType.Oidc, + Platform = ChallengePlatform.Unidentified, + Audiences = [ProviderName], + Scopes = ["*"], + AccountId = account.Id, + DeviceId = deviceId, + IpAddress = request.Connection.RemoteIpAddress?.ToString() ?? null, + UserAgent = request.Request.Headers.UserAgent, + }; + challenge.StepRemain--; + if (challenge.StepRemain < 0) challenge.StepRemain = 0; + + await Db.AuthChallenges.AddAsync(challenge); + await Db.SaveChangesAsync(); + + return challenge; + } +} + +/// +/// Provider configuration from app settings +/// +public class ProviderConfiguration +{ + public string ClientId { get; set; } = ""; + public string ClientSecret { get; set; } = ""; + public string RedirectUri { get; set; } = ""; +} + +/// +/// OIDC Discovery Document +/// +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; } +} + +/// +/// Response from the token endpoint +/// +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; } +} + +/// +/// Data received in the callback from an OIDC provider +/// +public class OidcCallbackData +{ + public string Code { get; set; } = ""; + public string IdToken { get; set; } = ""; + public string? State { get; set; } + public string? RawData { get; set; } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/OpenId/OidcState.cs b/DysonNetwork.Pass/Auth/OpenId/OidcState.cs new file mode 100644 index 0000000..4555a5e --- /dev/null +++ b/DysonNetwork.Pass/Auth/OpenId/OidcState.cs @@ -0,0 +1,189 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace DysonNetwork.Pass.Auth.OpenId; + +/// +/// Represents the state parameter used in OpenID Connect flows. +/// Handles serialization and deserialization of the state parameter. +/// +public class OidcState +{ + /// + /// The type of OIDC flow (login or connect). + /// + public OidcFlowType FlowType { get; set; } + + /// + /// The account ID (for connect flow). + /// + public Guid? AccountId { get; set; } + + + /// + /// The OIDC provider name. + /// + public string? Provider { get; set; } + + + /// + /// The nonce for CSRF protection. + /// + public string? Nonce { get; set; } + + + /// + /// The device ID for the authentication request. + /// + public string? DeviceId { get; set; } + + + /// + /// The return URL after authentication (for login flow). + /// + public string? ReturnUrl { get; set; } + + + /// + /// Creates a new OidcState for a connection flow. + /// + 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 + }; + } + + /// + /// Creates a new OidcState for a login flow. + /// + public static OidcState ForLogin(string returnUrl = "/", string? deviceId = null) + { + return new OidcState + { + FlowType = OidcFlowType.Login, + ReturnUrl = returnUrl, + DeviceId = deviceId + }; + } + + /// + /// The version of the state format. + /// + public int Version { get; set; } = 1; + + /// + /// Serializes the state to a JSON string for use in OIDC flows. + /// + public string Serialize() + { + return JsonSerializer.Serialize(this, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }); + } + + /// + /// Attempts to parse a state string into an OidcState object. + /// + 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(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; + } +} + +/// +/// Represents the type of OIDC flow. +/// +public enum OidcFlowType +{ + /// + /// Login or registration flow. + /// + Login, + + + /// + /// Account connection flow. + /// + Connect +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/OpenId/OidcUserInfo.cs b/DysonNetwork.Pass/Auth/OpenId/OidcUserInfo.cs new file mode 100644 index 0000000..973d76d --- /dev/null +++ b/DysonNetwork.Pass/Auth/OpenId/OidcUserInfo.cs @@ -0,0 +1,49 @@ +namespace DysonNetwork.Pass.Auth.OpenId; + +/// +/// Represents the user information from an OIDC provider +/// +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 ToMetadata() + { + var metadata = new Dictionary(); + + 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; + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/Session.cs b/DysonNetwork.Pass/Auth/Session.cs new file mode 100644 index 0000000..4fcfee9 --- /dev/null +++ b/DysonNetwork.Pass/Auth/Session.cs @@ -0,0 +1,69 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; +using DysonNetwork.Pass; +using NodaTime; +using Point = NetTopologySuite.Geometries.Point; + +namespace DysonNetwork.Pass.Auth; + +public class Session : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + [MaxLength(1024)] public string? Label { get; set; } + public Instant? LastGrantedAt { get; set; } + public Instant? ExpiredAt { get; set; } + + public Guid AccountId { get; set; } + [JsonIgnore] public Account.Account Account { get; set; } = null!; + public Guid ChallengeId { get; set; } + public Challenge Challenge { get; set; } = null!; + public Guid? AppId { get; set; } + // public CustomApp? App { get; set; } +} + +public enum ChallengeType +{ + Login, + OAuth, // Trying to authorize other platforms + Oidc // Trying to connect other platforms +} + +public enum ChallengePlatform +{ + Unidentified, + Web, + Ios, + Android, + MacOs, + Windows, + Linux +} + +public class Challenge : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Instant? ExpiredAt { get; set; } + public int StepRemain { get; set; } + public int StepTotal { get; set; } + public int FailedAttempts { get; set; } + public ChallengePlatform Platform { get; set; } = ChallengePlatform.Unidentified; + public ChallengeType Type { get; set; } = ChallengeType.Login; + [Column(TypeName = "jsonb")] public List BlacklistFactors { get; set; } = new(); + [Column(TypeName = "jsonb")] public List Audiences { get; set; } = new(); + [Column(TypeName = "jsonb")] public List Scopes { get; set; } = new(); + [MaxLength(128)] public string? IpAddress { get; set; } + [MaxLength(512)] public string? UserAgent { get; set; } + [MaxLength(256)] public string? DeviceId { get; set; } + [MaxLength(1024)] public string? Nonce { get; set; } + public Point? Location { get; set; } + + public Guid AccountId { get; set; } + [JsonIgnore] public Account.Account Account { get; set; } = null!; + + public Challenge Normalize() + { + if (StepRemain == 0 && BlacklistFactors.Count == 0) StepRemain = StepTotal; + return this; + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Dockerfile b/DysonNetwork.Pass/Dockerfile new file mode 100644 index 0000000..5c35aff --- /dev/null +++ b/DysonNetwork.Pass/Dockerfile @@ -0,0 +1,23 @@ +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["DysonNetwork.Pass/DysonNetwork.Pass.csproj", "DysonNetwork.Pass/"] +RUN dotnet restore "DysonNetwork.Pass/DysonNetwork.Pass.csproj" +COPY . . +WORKDIR "/src/DysonNetwork.Pass" +RUN dotnet build "./DysonNetwork.Pass.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./DysonNetwork.Pass.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "DysonNetwork.Pass.dll"] diff --git a/DysonNetwork.Pass/DysonNetwork.Pass.csproj b/DysonNetwork.Pass/DysonNetwork.Pass.csproj new file mode 100644 index 0000000..6869e9f --- /dev/null +++ b/DysonNetwork.Pass/DysonNetwork.Pass.csproj @@ -0,0 +1,42 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DysonNetwork.Pass/DysonNetwork.Pass.http b/DysonNetwork.Pass/DysonNetwork.Pass.http new file mode 100644 index 0000000..067647c --- /dev/null +++ b/DysonNetwork.Pass/DysonNetwork.Pass.http @@ -0,0 +1,6 @@ +@DysonNetwork.Pass_HostAddress = http://localhost:5216 + +GET {{DysonNetwork.Pass_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/DysonNetwork.Pass/Handlers/ActionLogFlushHandler.cs b/DysonNetwork.Pass/Handlers/ActionLogFlushHandler.cs new file mode 100644 index 0000000..d5e9f49 --- /dev/null +++ b/DysonNetwork.Pass/Handlers/ActionLogFlushHandler.cs @@ -0,0 +1,25 @@ +using DysonNetwork.Pass.Account; +using DysonNetwork.Shared.Cache; +using EFCore.BulkExtensions; +using Quartz; + +namespace DysonNetwork.Pass.Handlers; + +public class ActionLogFlushHandler(IServiceProvider serviceProvider) : IFlushHandler +{ + public async Task FlushAsync(IReadOnlyList items) + { + using var scope = serviceProvider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + await db.BulkInsertAsync(items, config => config.ConflictOption = ConflictOption.Ignore); + } +} + +public class ActionLogFlushJob(FlushBufferService fbs, ActionLogFlushHandler hdl) : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + await fbs.FlushAsync(hdl); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Handlers/LastActiveFlushHandler.cs b/DysonNetwork.Pass/Handlers/LastActiveFlushHandler.cs new file mode 100644 index 0000000..216a2c9 --- /dev/null +++ b/DysonNetwork.Pass/Handlers/LastActiveFlushHandler.cs @@ -0,0 +1,61 @@ +using DysonNetwork.Shared.Cache; +using Microsoft.EntityFrameworkCore; +using NodaTime; +using Quartz; + +namespace DysonNetwork.Pass.Handlers; + +public class LastActiveInfo +{ + public Auth.Session Session { get; set; } = null!; + public Account.Account Account { get; set; } = null!; + public Instant SeenAt { get; set; } +} + +public class LastActiveFlushHandler(IServiceProvider serviceProvider) : IFlushHandler +{ + public async Task FlushAsync(IReadOnlyList items) + { + using var scope = serviceProvider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Remove duplicates by grouping on (sessionId, accountId), taking the most recent SeenAt + var distinctItems = items + .GroupBy(x => (SessionId: x.Session.Id, AccountId: x.Account.Id)) + .Select(g => g.OrderByDescending(x => x.SeenAt).First()) + .ToList(); + + // Build dictionaries so we can match session/account IDs to their new "last seen" timestamps + var sessionIdMap = distinctItems + .GroupBy(x => x.Session.Id) + .ToDictionary(g => g.Key, g => g.Last().SeenAt); + + var accountIdMap = distinctItems + .GroupBy(x => x.Account.Id) + .ToDictionary(g => g.Key, g => g.Last().SeenAt); + + // Update sessions using native EF Core ExecuteUpdateAsync + foreach (var kvp in sessionIdMap) + { + await db.AuthSessions + .Where(s => s.Id == kvp.Key) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.LastGrantedAt, kvp.Value)); + } + + // Update account profiles using native EF Core ExecuteUpdateAsync + foreach (var kvp in accountIdMap) + { + await db.AccountProfiles + .Where(a => a.AccountId == kvp.Key) + .ExecuteUpdateAsync(a => a.SetProperty(x => x.LastSeenAt, kvp.Value)); + } + } +} + +public class LastActiveFlushJob(FlushBufferService fbs, ActionLogFlushHandler hdl) : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + await fbs.FlushAsync(hdl); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Permission/Permission.cs b/DysonNetwork.Pass/Permission/Permission.cs new file mode 100644 index 0000000..8a0eb4a --- /dev/null +++ b/DysonNetwork.Pass/Permission/Permission.cs @@ -0,0 +1,59 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace DysonNetwork.Pass.Permission; + +/// The permission node model provides the infrastructure of permission control in Dyson Network. +/// It based on the ABAC permission model. +/// +/// The value can be any type, boolean and number for most cases and stored in jsonb. +/// +/// The area represents the region this permission affects. For example, the pub:<publisherId> +/// indicates it's a permission node for the publishers managing. +/// +/// And the actor shows who owns the permission, in most cases, the user:<userId> +/// and when the permission node has a GroupId, the actor will be set to the group, but it won't work on checking +/// expect the member of that permission group inherent the permission from the group. +[Index(nameof(Key), nameof(Area), nameof(Actor))] +public class PermissionNode : ModelBase, IDisposable +{ + public Guid Id { get; set; } = Guid.NewGuid(); + [MaxLength(1024)] public string Actor { get; set; } = null!; + [MaxLength(1024)] public string Area { get; set; } = null!; + [MaxLength(1024)] public string Key { get; set; } = null!; + [Column(TypeName = "jsonb")] public JsonDocument Value { get; set; } = null!; + public Instant? ExpiredAt { get; set; } = null; + public Instant? AffectedAt { get; set; } = null; + + public Guid? GroupId { get; set; } = null; + [JsonIgnore] public PermissionGroup? Group { get; set; } = null; + + public void Dispose() + { + Value.Dispose(); + GC.SuppressFinalize(this); + } +} + +public class PermissionGroup : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + [MaxLength(1024)] public string Key { get; set; } = null!; + + public ICollection Nodes { get; set; } = new List(); + [JsonIgnore] public ICollection Members { get; set; } = new List(); +} + +public class PermissionGroupMember : ModelBase +{ + public Guid GroupId { get; set; } + public PermissionGroup Group { get; set; } = null!; + [MaxLength(1024)] public string Actor { get; set; } = null!; + + public Instant? ExpiredAt { get; set; } + public Instant? AffectedAt { get; set; } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Permission/PermissionMiddleware.cs b/DysonNetwork.Pass/Permission/PermissionMiddleware.cs new file mode 100644 index 0000000..e1011fc --- /dev/null +++ b/DysonNetwork.Pass/Permission/PermissionMiddleware.cs @@ -0,0 +1,51 @@ +namespace DysonNetwork.Pass.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() + .FirstOrDefault(); + + if (attr != null) + { + if (httpContext.Items["CurrentUser"] is not Account.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(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); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Permission/PermissionService.cs b/DysonNetwork.Pass/Permission/PermissionService.cs new file mode 100644 index 0000000..0ff2812 --- /dev/null +++ b/DysonNetwork.Pass/Permission/PermissionService.cs @@ -0,0 +1,198 @@ +using Microsoft.EntityFrameworkCore; +using NodaTime; +using System.Text.Json; +using DysonNetwork.Pass; +using DysonNetwork.Shared.Cache; + +namespace DysonNetwork.Pass.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 HasPermissionAsync(string actor, string area, string key) + { + var value = await GetPermissionAsync(actor, area, key); + return value; + } + + public async Task GetPermissionAsync(string actor, string area, string key) + { + var cacheKey = _GetPermissionCacheKey(actor, area, key); + + var (hit, cachedValue) = await cache.GetAsyncWithStatus(cacheKey); + if (hit) + return cachedValue; + + var now = SystemClock.Instance.GetCurrentInstant(); + var groupsKey = _GetGroupsCacheKey(actor); + + var groupsId = await cache.GetAsync>(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(permission.Value) : default; + + await cache.SetWithGroupsAsync(cacheKey, result, + [_GetPermissionGroupKey(actor)], + CacheExpiration); + + return result; + } + + public async Task AddPermissionNode( + 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 AddPermissionNodeToGroup( + 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(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(JsonDocument json) + { + return JsonSerializer.Deserialize(json.RootElement.GetRawText()); + } + + private static JsonDocument _SerializePermissionValue(T obj) + { + var str = JsonSerializer.Serialize(obj); + return JsonDocument.Parse(str); + } + + public static PermissionNode NewPermissionNode(string actor, string area, string key, T value) + { + return new PermissionNode + { + Actor = actor, + Area = area, + Key = key, + Value = _SerializePermissionValue(value), + }; + } +} \ No newline at end of file diff --git a/DysonNetwork.Pass/Program.cs b/DysonNetwork.Pass/Program.cs new file mode 100644 index 0000000..666a9c5 --- /dev/null +++ b/DysonNetwork.Pass/Program.cs @@ -0,0 +1,23 @@ +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddControllers(); +// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/DysonNetwork.Pass/Properties/launchSettings.json b/DysonNetwork.Pass/Properties/launchSettings.json new file mode 100644 index 0000000..23c7313 --- /dev/null +++ b/DysonNetwork.Pass/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5216", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7058;http://localhost:5216", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/DysonNetwork.Pass/WeatherForecast.cs b/DysonNetwork.Pass/WeatherForecast.cs new file mode 100644 index 0000000..74ce1c5 --- /dev/null +++ b/DysonNetwork.Pass/WeatherForecast.cs @@ -0,0 +1,12 @@ +namespace DysonNetwork.Pass; + +public class WeatherForecast +{ + public DateOnly Date { get; set; } + + public int TemperatureC { get; set; } + + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); + + public string? Summary { get; set; } +} diff --git a/DysonNetwork.Pass/appsettings.Development.json b/DysonNetwork.Pass/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/DysonNetwork.Pass/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/DysonNetwork.Pass/appsettings.json b/DysonNetwork.Pass/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/DysonNetwork.Pass/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/DysonNetwork.Shared/Cache/CacheService.cs b/DysonNetwork.Shared/Cache/CacheService.cs new file mode 100644 index 0000000..2157bb7 --- /dev/null +++ b/DysonNetwork.Shared/Cache/CacheService.cs @@ -0,0 +1,396 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using NodaTime; +using NodaTime.Serialization.JsonNet; +using StackExchange.Redis; + +namespace DysonNetwork.Shared.Cache; + +/// +/// Represents a distributed lock that can be used to synchronize access across multiple processes +/// +public interface IDistributedLock : IAsyncDisposable +{ + /// + /// The resource identifier this lock is protecting + /// + string Resource { get; } + + /// + /// Unique identifier for this lock instance + /// + string LockId { get; } + + /// + /// Extends the lock's expiration time + /// + Task ExtendAsync(TimeSpan timeSpan); + + /// + /// Releases the lock immediately + /// + Task ReleaseAsync(); +} + +public interface ICacheService +{ + /// + /// Sets a value in the cache with an optional expiration time + /// + Task SetAsync(string key, T value, TimeSpan? expiry = null); + + /// + /// Gets a value from the cache + /// + Task GetAsync(string key); + + /// + /// Get a value from the cache with the found status + /// + Task<(bool found, T? value)> GetAsyncWithStatus(string key); + + /// + /// Removes a specific key from the cache + /// + Task RemoveAsync(string key); + + /// + /// Adds a key to a group for group-based operations + /// + Task AddToGroupAsync(string key, string group); + + /// + /// Removes all keys associated with a specific group + /// + Task RemoveGroupAsync(string group); + + /// + /// Gets all keys belonging to a specific group + /// + Task> GetGroupKeysAsync(string group); + + /// + /// Helper method to set a value in cache and associate it with multiple groups in one operation + /// + /// The type of value being cached + /// Cache key + /// The value to cache + /// Optional collection of group names to associate the key with + /// Optional expiration time for the cached item + /// True if the set operation was successful + Task SetWithGroupsAsync(string key, T value, IEnumerable? groups = null, TimeSpan? expiry = null); + + /// + /// Acquires a distributed lock on the specified resource + /// + /// The resource identifier to lock + /// How long the lock should be held before automatically expiring + /// How long to wait for the lock before giving up + /// How often to retry acquiring the lock during the wait time + /// A distributed lock instance if acquired, null otherwise + Task AcquireLockAsync(string resource, TimeSpan expiry, TimeSpan? waitTime = null, + TimeSpan? retryInterval = null); + + /// + /// Executes an action with a distributed lock, ensuring the lock is properly released afterwards + /// + /// The resource identifier to lock + /// The action to execute while holding the lock + /// How long the lock should be held before automatically expiring + /// How long to wait for the lock before giving up + /// How often to retry acquiring the lock during the wait time + /// True if the lock was acquired and the action was executed, false otherwise + Task ExecuteWithLockAsync(string resource, Func action, TimeSpan expiry, TimeSpan? waitTime = null, + TimeSpan? retryInterval = null); + + /// + /// Executes a function with a distributed lock, ensuring the lock is properly released afterwards + /// + /// The return type of the function + /// The resource identifier to lock + /// The function to execute while holding the lock + /// How long the lock should be held before automatically expiring + /// How long to wait for the lock before giving up + /// How often to retry acquiring the lock during the wait time + /// The result of the function if the lock was acquired, default(T) otherwise + Task<(bool Acquired, T? Result)> ExecuteWithLockAsync(string resource, Func> 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 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 SetAsync(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 GetAsync(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(value!, _serializerSettings); + } + + public async Task<(bool found, T? value)> GetAsyncWithStatus(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(value!, _serializerSettings)); + } + + public async Task 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> 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 SetWithGroupsAsync(string key, T value, IEnumerable? 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 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 ExecuteWithLockAsync(string resource, Func 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(string resource, Func> 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); + } +} \ No newline at end of file diff --git a/DysonNetwork.Shared/Cache/FlushBufferService.cs b/DysonNetwork.Shared/Cache/FlushBufferService.cs new file mode 100644 index 0000000..fc7810d --- /dev/null +++ b/DysonNetwork.Shared/Cache/FlushBufferService.cs @@ -0,0 +1,66 @@ +using System.Collections.Concurrent; + +namespace DysonNetwork.Shared.Cache; + +public interface IFlushHandler +{ + Task FlushAsync(IReadOnlyList items); +} + +public class FlushBufferService +{ + private readonly Dictionary _buffers = new(); + private readonly Lock _lockObject = new(); + + private ConcurrentQueue _GetOrCreateBuffer() + { + var type = typeof(T); + lock (_lockObject) + { + if (!_buffers.TryGetValue(type, out var buffer)) + { + buffer = new ConcurrentQueue(); + _buffers[type] = buffer; + } + return (ConcurrentQueue)buffer; + } + } + + public void Enqueue(T item) + { + var buffer = _GetOrCreateBuffer(); + buffer.Enqueue(item); + } + + public async Task FlushAsync(IFlushHandler handler) + { + var buffer = _GetOrCreateBuffer(); + var workingQueue = new List(); + + while (buffer.TryDequeue(out var item)) + { + workingQueue.Add(item); + } + + if (workingQueue.Count == 0) + return; + + try + { + await handler.FlushAsync(workingQueue); + } + catch (Exception) + { + // If flush fails, re-queue the items + foreach (var item in workingQueue) + buffer.Enqueue(item); + throw; + } + } + + public int GetPendingCount() + { + var buffer = _GetOrCreateBuffer(); + return buffer.Count; + } +} \ No newline at end of file diff --git a/DysonNetwork.Shared/DysonNetwork.Shared.csproj b/DysonNetwork.Shared/DysonNetwork.Shared.csproj new file mode 100644 index 0000000..6092e6f --- /dev/null +++ b/DysonNetwork.Shared/DysonNetwork.Shared.csproj @@ -0,0 +1,18 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + diff --git a/DysonNetwork.Shared/Geo/GeoIpService.cs b/DysonNetwork.Shared/Geo/GeoIpService.cs new file mode 100644 index 0000000..9507bf2 --- /dev/null +++ b/DysonNetwork.Shared/Geo/GeoIpService.cs @@ -0,0 +1,56 @@ +using MaxMind.GeoIP2; +using Microsoft.Extensions.Options; +using NetTopologySuite.Geometries; +using Point = NetTopologySuite.Geometries.Point; + +namespace DysonNetwork.Shared.Geo; + +public class GeoIpOptions +{ + public string DatabasePath { get; set; } = null!; +} + +public class GeoIpService(IOptions options) +{ + private readonly string _databasePath = options.Value.DatabasePath; + private readonly GeometryFactory _geometryFactory = new(new PrecisionModel(), 4326); // 4326 is the SRID for WGS84 + + public Point? GetPointFromIp(string? ipAddress) + { + if (string.IsNullOrEmpty(ipAddress)) + return null; + + try + { + using var reader = new DatabaseReader(_databasePath); + var city = reader.City(ipAddress); + + if (city?.Location == null || !city.Location.HasCoordinates) + return null; + + return _geometryFactory.CreatePoint(new Coordinate( + city.Location.Longitude ?? 0, + city.Location.Latitude ?? 0)); + } + catch (Exception) + { + return null; + } + } + + public MaxMind.GeoIP2.Responses.CityResponse? GetFromIp(string? ipAddress) + { + if (string.IsNullOrEmpty(ipAddress)) + return null; + + try + { + using var reader = new DatabaseReader(_databasePath); + return reader.City(ipAddress); + } + catch (Exception) + { + return null; + } + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj index d1c90df..f7c5de9 100644 --- a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj +++ b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj @@ -34,7 +34,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -69,7 +69,7 @@ - + diff --git a/DysonNetwork.Sphere/Storage/FileController.cs b/DysonNetwork.Sphere/Storage/FileController.cs index 32f14c6..d13f531 100644 --- a/DysonNetwork.Sphere/Storage/FileController.cs +++ b/DysonNetwork.Sphere/Storage/FileController.cs @@ -26,11 +26,11 @@ public class FileController( { // Support the file extension for client side data recognize string? fileExtension = null; - if (id.Contains(".")) + if (id.Contains('.')) { - var splitedId = id.Split('.'); - id = splitedId.First(); - fileExtension = splitedId.Last(); + var splitId = id.Split('.'); + id = splitId.First(); + fileExtension = splitId.Last(); } var file = await fs.GetFileAsync(id); diff --git a/DysonNetwork.sln b/DysonNetwork.sln index ea7c8d0..7ee589d 100644 --- a/DysonNetwork.sln +++ b/DysonNetwork.sln @@ -7,6 +7,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution compose.yaml = compose.yaml EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Pass", "DysonNetwork.Pass\DysonNetwork.Pass.csproj", "{A8F37E9E-52A4-4159-8227-F2F65CBA0606}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Shared", "DysonNetwork.Shared\DysonNetwork.Shared.csproj", "{DB46D1A6-79B4-43FC-A9A9-115CDA26947A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -17,5 +21,13 @@ Global {CFF62EFA-F4C2-4FC7-8D97-25570B4DB452}.Debug|Any CPU.Build.0 = Debug|Any CPU {CFF62EFA-F4C2-4FC7-8D97-25570B4DB452}.Release|Any CPU.ActiveCfg = Release|Any CPU {CFF62EFA-F4C2-4FC7-8D97-25570B4DB452}.Release|Any CPU.Build.0 = Release|Any CPU + {A8F37E9E-52A4-4159-8227-F2F65CBA0606}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A8F37E9E-52A4-4159-8227-F2F65CBA0606}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A8F37E9E-52A4-4159-8227-F2F65CBA0606}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A8F37E9E-52A4-4159-8227-F2F65CBA0606}.Release|Any CPU.Build.0 = Release|Any CPU + {DB46D1A6-79B4-43FC-A9A9-115CDA26947A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DB46D1A6-79B4-43FC-A9A9-115CDA26947A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DB46D1A6-79B4-43FC-A9A9-115CDA26947A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DB46D1A6-79B4-43FC-A9A9-115CDA26947A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/DysonNetwork.sln.DotSettings.user b/DysonNetwork.sln.DotSettings.user index 897f1de..069ceae 100644 --- a/DysonNetwork.sln.DotSettings.user +++ b/DysonNetwork.sln.DotSettings.user @@ -146,4 +146,4 @@ True False - True \ No newline at end of file + False \ No newline at end of file