♻️ Moved some services to DysonNetwork.Pass
This commit is contained in:
30
DysonNetwork.Pass/Account/AbuseReport.cs
Normal file
30
DysonNetwork.Pass/Account/AbuseReport.cs
Normal file
@ -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!;
|
||||||
|
}
|
190
DysonNetwork.Pass/Account/Account.cs
Normal file
190
DysonNetwork.Pass/Account/Account.cs
Normal file
@ -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<AccountContact> Contacts { get; set; } = new List<AccountContact>();
|
||||||
|
public ICollection<Badge> Badges { get; set; } = new List<Badge>();
|
||||||
|
|
||||||
|
[JsonIgnore] public ICollection<AccountAuthFactor> AuthFactors { get; set; } = new List<AccountAuthFactor>();
|
||||||
|
[JsonIgnore] public ICollection<AccountConnection> Connections { get; set; } = new List<AccountConnection>();
|
||||||
|
[JsonIgnore] public ICollection<Auth.Session> Sessions { get; set; } = new List<Auth.Session>();
|
||||||
|
[JsonIgnore] public ICollection<Auth.Challenge> Challenges { get; set; } = new List<Auth.Challenge>();
|
||||||
|
|
||||||
|
[JsonIgnore] public ICollection<Relationship> OutgoingRelationships { get; set; } = new List<Relationship>();
|
||||||
|
[JsonIgnore] public ICollection<Relationship> IncomingRelationships { get; set; } = new List<Relationship>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract class Leveling
|
||||||
|
{
|
||||||
|
public static readonly List<int> ExperiencePerLevel =
|
||||||
|
[
|
||||||
|
0, // Level 0
|
||||||
|
100, // Level 1
|
||||||
|
250, // Level 2
|
||||||
|
500, // Level 3
|
||||||
|
1000, // Level 4
|
||||||
|
2000, // Level 5
|
||||||
|
4000, // Level 6
|
||||||
|
8000, // Level 7
|
||||||
|
16000, // Level 8
|
||||||
|
32000, // Level 9
|
||||||
|
64000, // Level 10
|
||||||
|
128000, // Level 11
|
||||||
|
256000, // Level 12
|
||||||
|
512000, // Level 13
|
||||||
|
1024000 // Level 14
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Profile : ModelBase
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
[MaxLength(256)] public string? FirstName { get; set; }
|
||||||
|
[MaxLength(256)] public string? MiddleName { get; set; }
|
||||||
|
[MaxLength(256)] public string? LastName { get; set; }
|
||||||
|
[MaxLength(4096)] public string? Bio { get; set; }
|
||||||
|
[MaxLength(1024)] public string? Gender { get; set; }
|
||||||
|
[MaxLength(1024)] public string? Pronouns { get; set; }
|
||||||
|
[MaxLength(1024)] public string? TimeZone { get; set; }
|
||||||
|
[MaxLength(1024)] public string? Location { get; set; }
|
||||||
|
public Instant? Birthday { get; set; }
|
||||||
|
public Instant? LastSeenAt { get; set; }
|
||||||
|
|
||||||
|
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
|
||||||
|
[Column(TypeName = "jsonb")] public BadgeReferenceObject? ActiveBadge { get; set; }
|
||||||
|
|
||||||
|
public int Experience { get; set; } = 0;
|
||||||
|
[NotMapped] public int Level => Leveling.ExperiencePerLevel.Count(xp => Experience >= xp) - 1;
|
||||||
|
|
||||||
|
[NotMapped]
|
||||||
|
public double LevelingProgress => Level >= Leveling.ExperiencePerLevel.Count - 1
|
||||||
|
? 100
|
||||||
|
: (Experience - Leveling.ExperiencePerLevel[Level]) * 100.0 /
|
||||||
|
(Leveling.ExperiencePerLevel[Level + 1] - Leveling.ExperiencePerLevel[Level]);
|
||||||
|
|
||||||
|
// Outdated fields, for backward compability
|
||||||
|
[MaxLength(32)] public string? PictureId { get; set; }
|
||||||
|
[MaxLength(32)] public string? BackgroundId { get; set; }
|
||||||
|
|
||||||
|
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
|
||||||
|
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
|
||||||
|
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
[JsonIgnore] public Account Account { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AccountContact : ModelBase
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public AccountContactType Type { get; set; }
|
||||||
|
public Instant? VerifiedAt { get; set; }
|
||||||
|
public bool IsPrimary { get; set; } = false;
|
||||||
|
[MaxLength(1024)] public string Content { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
[JsonIgnore] public Account Account { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AccountContactType
|
||||||
|
{
|
||||||
|
Email,
|
||||||
|
PhoneNumber,
|
||||||
|
Address
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AccountAuthFactor : ModelBase
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public AccountAuthFactorType Type { get; set; }
|
||||||
|
[JsonIgnore] [MaxLength(8196)] public string? Secret { get; set; }
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
[Column(TypeName = "jsonb")]
|
||||||
|
public Dictionary<string, object>? Config { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The trustworthy stands for how safe is this auth factor.
|
||||||
|
/// Basically, it affects how many steps it can complete in authentication.
|
||||||
|
/// Besides, users may need to use some high-trustworthy level auth factors when confirming some dangerous operations.
|
||||||
|
/// </summary>
|
||||||
|
public int Trustworthy { get; set; } = 1;
|
||||||
|
|
||||||
|
public Instant? EnabledAt { get; set; }
|
||||||
|
public Instant? ExpiredAt { get; set; }
|
||||||
|
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
[JsonIgnore] public Account Account { get; set; } = null!;
|
||||||
|
|
||||||
|
public AccountAuthFactor HashSecret(int cost = 12)
|
||||||
|
{
|
||||||
|
if (Secret == null) return this;
|
||||||
|
Secret = BCrypt.Net.BCrypt.HashPassword(Secret, workFactor: cost);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool VerifyPassword(string password)
|
||||||
|
{
|
||||||
|
if (Secret == null)
|
||||||
|
throw new InvalidOperationException("Auth factor with no secret cannot be verified with password.");
|
||||||
|
switch (Type)
|
||||||
|
{
|
||||||
|
case AccountAuthFactorType.Password:
|
||||||
|
case AccountAuthFactorType.PinCode:
|
||||||
|
return BCrypt.Net.BCrypt.Verify(password, Secret);
|
||||||
|
case AccountAuthFactorType.TimedCode:
|
||||||
|
var otp = new Totp(Base32Encoding.ToBytes(Secret));
|
||||||
|
return otp.VerifyTotp(DateTime.UtcNow, password, out _, new VerificationWindow(previous: 5, future: 5));
|
||||||
|
case AccountAuthFactorType.EmailCode:
|
||||||
|
case AccountAuthFactorType.InAppCode:
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException("Unsupported verification type, use CheckDeliveredCode instead.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This dictionary will be returned to the client and should only be set when it just created.
|
||||||
|
/// Useful for passing the client some data to finishing setup and recovery code.
|
||||||
|
/// </summary>
|
||||||
|
[NotMapped]
|
||||||
|
public Dictionary<string, object>? CreatedResponse { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AccountAuthFactorType
|
||||||
|
{
|
||||||
|
Password,
|
||||||
|
EmailCode,
|
||||||
|
InAppCode,
|
||||||
|
TimedCode,
|
||||||
|
PinCode,
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AccountConnection : ModelBase
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
[MaxLength(4096)] public string Provider { get; set; } = null!;
|
||||||
|
[MaxLength(8192)] public string ProvidedIdentifier { get; set; } = null!;
|
||||||
|
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonIgnore] [MaxLength(4096)] public string? AccessToken { get; set; }
|
||||||
|
[JsonIgnore] [MaxLength(4096)] public string? RefreshToken { get; set; }
|
||||||
|
public Instant? LastUsedAt { get; set; }
|
||||||
|
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
public Account Account { get; set; } = null!;
|
||||||
|
}
|
178
DysonNetwork.Pass/Account/AccountController.cs
Normal file
178
DysonNetwork.Pass/Account/AccountController.cs
Normal file
@ -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<Account>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<Account?>> GetByName(string name)
|
||||||
|
{
|
||||||
|
var account = await db.Accounts
|
||||||
|
.Include(e => e.Badges)
|
||||||
|
.Include(e => e.Profile)
|
||||||
|
.Where(a => a.Name == name)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
return account is null ? new NotFoundResult() : account;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{name}/badges")]
|
||||||
|
[ProducesResponseType<List<Badge>>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<List<Badge>>> GetBadgesByName(string name)
|
||||||
|
{
|
||||||
|
var account = await db.Accounts
|
||||||
|
.Include(e => e.Badges)
|
||||||
|
.Where(a => a.Name == name)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
return account is null ? NotFound() : account.Badges.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AccountCreateRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[MinLength(2)]
|
||||||
|
[MaxLength(256)]
|
||||||
|
[RegularExpression(@"^[A-Za-z0-9_-]+$",
|
||||||
|
ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.")
|
||||||
|
]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required] [MaxLength(256)] public string Nick { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[EmailAddress]
|
||||||
|
[RegularExpression(@"^[^+]+@[^@]+\.[^@]+$", ErrorMessage = "Email address cannot contain '+' symbol.")]
|
||||||
|
[Required]
|
||||||
|
[MaxLength(1024)]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MinLength(4)]
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(128)] public string Language { get; set; } = "en-us";
|
||||||
|
|
||||||
|
[Required] public string CaptchaToken { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<ActionResult<Account>> CreateAccount([FromBody] AccountCreateRequest request)
|
||||||
|
{
|
||||||
|
if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var account = await accounts.CreateAccount(
|
||||||
|
request.Name,
|
||||||
|
request.Nick,
|
||||||
|
request.Email,
|
||||||
|
request.Password,
|
||||||
|
request.Language
|
||||||
|
);
|
||||||
|
return Ok(account);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RecoveryPasswordRequest
|
||||||
|
{
|
||||||
|
[Required] public string Account { get; set; } = null!;
|
||||||
|
[Required] public string CaptchaToken { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("recovery/password")]
|
||||||
|
public async Task<ActionResult> RequestResetPassword([FromBody] RecoveryPasswordRequest request)
|
||||||
|
{
|
||||||
|
if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token.");
|
||||||
|
|
||||||
|
var account = await accounts.LookupAccount(request.Account);
|
||||||
|
if (account is null) return BadRequest("Unable to find the account.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await accounts.RequestPasswordReset(account);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
return BadRequest("You already requested password reset within 24 hours.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class StatusRequest
|
||||||
|
{
|
||||||
|
public StatusAttitude Attitude { get; set; }
|
||||||
|
public bool IsInvisible { get; set; }
|
||||||
|
public bool IsNotDisturb { get; set; }
|
||||||
|
[MaxLength(1024)] public string? Label { get; set; }
|
||||||
|
public Instant? ClearedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{name}/statuses")]
|
||||||
|
public async Task<ActionResult<Status>> GetOtherStatus(string name)
|
||||||
|
{
|
||||||
|
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name);
|
||||||
|
if (account is null) return BadRequest();
|
||||||
|
var status = await events.GetStatus(account.Id);
|
||||||
|
status.IsInvisible = false; // Keep the invisible field not available for other users
|
||||||
|
return Ok(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{name}/calendar")]
|
||||||
|
public async Task<ActionResult<List<DailyEventResponse>>> GetOtherEventCalendar(
|
||||||
|
string name,
|
||||||
|
[FromQuery] int? month,
|
||||||
|
[FromQuery] int? year
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var currentDate = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
|
||||||
|
month ??= currentDate.Month;
|
||||||
|
year ??= currentDate.Year;
|
||||||
|
|
||||||
|
if (month is < 1 or > 12) return BadRequest("Invalid month.");
|
||||||
|
if (year < 1) return BadRequest("Invalid year.");
|
||||||
|
|
||||||
|
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Name == name);
|
||||||
|
if (account is null) return BadRequest();
|
||||||
|
|
||||||
|
var calendar = await events.GetEventCalendar(account, month.Value, year.Value, replaceInvisible: true);
|
||||||
|
return Ok(calendar);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("search")]
|
||||||
|
public async Task<List<Account>> Search([FromQuery] string query, [FromQuery] int take = 20)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
|
return [];
|
||||||
|
return await db.Accounts
|
||||||
|
.Include(e => e.Profile)
|
||||||
|
.Where(a => EF.Functions.ILike(a.Name, $"%{query}%") ||
|
||||||
|
EF.Functions.ILike(a.Nick, $"%{query}%"))
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
}
|
703
DysonNetwork.Pass/Account/AccountCurrentController.cs
Normal file
703
DysonNetwork.Pass/Account/AccountCurrentController.cs
Normal file
@ -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<Account>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<Account>> GetCurrentIdentity()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
var userId = currentUser.Id;
|
||||||
|
|
||||||
|
var account = await db.Accounts
|
||||||
|
.Include(e => e.Badges)
|
||||||
|
.Include(e => e.Profile)
|
||||||
|
.Where(e => e.Id == userId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
return Ok(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BasicInfoRequest
|
||||||
|
{
|
||||||
|
[MaxLength(256)] public string? Nick { get; set; }
|
||||||
|
[MaxLength(32)] public string? Language { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch]
|
||||||
|
public async Task<ActionResult<Account>> UpdateBasicInfo([FromBody] BasicInfoRequest request)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var account = await db.Accounts.FirstAsync(a => a.Id == currentUser.Id);
|
||||||
|
|
||||||
|
if (request.Nick is not null) account.Nick = request.Nick;
|
||||||
|
if (request.Language is not null) account.Language = request.Language;
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
await accounts.PurgeAccountCache(currentUser);
|
||||||
|
return currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ProfileRequest
|
||||||
|
{
|
||||||
|
[MaxLength(256)] public string? FirstName { get; set; }
|
||||||
|
[MaxLength(256)] public string? MiddleName { get; set; }
|
||||||
|
[MaxLength(256)] public string? LastName { get; set; }
|
||||||
|
[MaxLength(1024)] public string? Gender { get; set; }
|
||||||
|
[MaxLength(1024)] public string? Pronouns { get; set; }
|
||||||
|
[MaxLength(1024)] public string? TimeZone { get; set; }
|
||||||
|
[MaxLength(1024)] public string? Location { get; set; }
|
||||||
|
[MaxLength(4096)] public string? Bio { get; set; }
|
||||||
|
public Instant? Birthday { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(32)] public string? PictureId { get; set; }
|
||||||
|
[MaxLength(32)] public string? BackgroundId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("profile")]
|
||||||
|
public async Task<ActionResult<Profile>> UpdateProfile([FromBody] ProfileRequest request)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
var userId = currentUser.Id;
|
||||||
|
|
||||||
|
var profile = await db.AccountProfiles
|
||||||
|
.Where(p => p.Account.Id == userId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (profile is null) return BadRequest("Unable to get your account.");
|
||||||
|
|
||||||
|
if (request.FirstName is not null) profile.FirstName = request.FirstName;
|
||||||
|
if (request.MiddleName is not null) profile.MiddleName = request.MiddleName;
|
||||||
|
if (request.LastName is not null) profile.LastName = request.LastName;
|
||||||
|
if (request.Bio is not null) profile.Bio = request.Bio;
|
||||||
|
if (request.Gender is not null) profile.Gender = request.Gender;
|
||||||
|
if (request.Pronouns is not null) profile.Pronouns = request.Pronouns;
|
||||||
|
if (request.Birthday is not null) profile.Birthday = request.Birthday;
|
||||||
|
if (request.Location is not null) profile.Location = request.Location;
|
||||||
|
if (request.TimeZone is not null) profile.TimeZone = request.TimeZone;
|
||||||
|
|
||||||
|
if (request.PictureId is not null)
|
||||||
|
{
|
||||||
|
var picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync();
|
||||||
|
if (picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud.");
|
||||||
|
|
||||||
|
var profileResourceId = $"profile:{profile.Id}";
|
||||||
|
|
||||||
|
// Remove old references for the profile picture
|
||||||
|
if (profile.Picture is not null)
|
||||||
|
{
|
||||||
|
var oldPictureRefs =
|
||||||
|
await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.picture");
|
||||||
|
foreach (var oldRef in oldPictureRefs)
|
||||||
|
{
|
||||||
|
await fileRefService.DeleteReferenceAsync(oldRef.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
profile.Picture = picture.ToReferenceObject();
|
||||||
|
|
||||||
|
// Create new reference
|
||||||
|
await fileRefService.CreateReferenceAsync(
|
||||||
|
picture.Id,
|
||||||
|
"profile.picture",
|
||||||
|
profileResourceId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.BackgroundId is not null)
|
||||||
|
{
|
||||||
|
var background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync();
|
||||||
|
if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud.");
|
||||||
|
|
||||||
|
var profileResourceId = $"profile:{profile.Id}";
|
||||||
|
|
||||||
|
// Remove old references for the profile background
|
||||||
|
if (profile.Background is not null)
|
||||||
|
{
|
||||||
|
var oldBackgroundRefs =
|
||||||
|
await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.background");
|
||||||
|
foreach (var oldRef in oldBackgroundRefs)
|
||||||
|
{
|
||||||
|
await fileRefService.DeleteReferenceAsync(oldRef.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
profile.Background = background.ToReferenceObject();
|
||||||
|
|
||||||
|
// Create new reference
|
||||||
|
await fileRefService.CreateReferenceAsync(
|
||||||
|
background.Id,
|
||||||
|
"profile.background",
|
||||||
|
profileResourceId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Update(profile);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await accounts.PurgeAccountCache(currentUser);
|
||||||
|
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete]
|
||||||
|
public async Task<ActionResult> RequestDeleteAccount()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await accounts.RequestAccountDeletion(currentUser);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
return BadRequest("You already requested account deletion within 24 hours.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("statuses")]
|
||||||
|
public async Task<ActionResult<Status>> GetCurrentStatus()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
var status = await events.GetStatus(currentUser.Id);
|
||||||
|
return Ok(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("statuses")]
|
||||||
|
[RequiredPermission("global", "accounts.statuses.update")]
|
||||||
|
public async Task<ActionResult<Status>> UpdateStatus([FromBody] AccountController.StatusRequest request)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var status = await db.AccountStatuses
|
||||||
|
.Where(e => e.AccountId == currentUser.Id)
|
||||||
|
.Where(e => e.ClearedAt == null || e.ClearedAt > now)
|
||||||
|
.OrderByDescending(e => e.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (status is null) return NotFound();
|
||||||
|
|
||||||
|
status.Attitude = request.Attitude;
|
||||||
|
status.IsInvisible = request.IsInvisible;
|
||||||
|
status.IsNotDisturb = request.IsNotDisturb;
|
||||||
|
status.Label = request.Label;
|
||||||
|
status.ClearedAt = request.ClearedAt;
|
||||||
|
|
||||||
|
db.Update(status);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
events.PurgeStatusCache(currentUser.Id);
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("statuses")]
|
||||||
|
[RequiredPermission("global", "accounts.statuses.create")]
|
||||||
|
public async Task<ActionResult<Status>> CreateStatus([FromBody] AccountController.StatusRequest request)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var status = new Status
|
||||||
|
{
|
||||||
|
AccountId = currentUser.Id,
|
||||||
|
Attitude = request.Attitude,
|
||||||
|
IsInvisible = request.IsInvisible,
|
||||||
|
IsNotDisturb = request.IsNotDisturb,
|
||||||
|
Label = request.Label,
|
||||||
|
ClearedAt = request.ClearedAt
|
||||||
|
};
|
||||||
|
|
||||||
|
return await events.CreateStatus(currentUser, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("me/statuses")]
|
||||||
|
public async Task<ActionResult> DeleteStatus()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var status = await db.AccountStatuses
|
||||||
|
.Where(s => s.AccountId == currentUser.Id)
|
||||||
|
.Where(s => s.ClearedAt == null || s.ClearedAt > now)
|
||||||
|
.OrderByDescending(s => s.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (status is null) return NotFound();
|
||||||
|
|
||||||
|
await events.ClearStatus(currentUser, status);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("check-in")]
|
||||||
|
public async Task<ActionResult<CheckInResult>> GetCheckInResult()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
var userId = currentUser.Id;
|
||||||
|
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var today = now.InUtc().Date;
|
||||||
|
var startOfDay = today.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
|
||||||
|
var endOfDay = today.PlusDays(1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
|
||||||
|
|
||||||
|
var result = await db.AccountCheckInResults
|
||||||
|
.Where(x => x.AccountId == userId)
|
||||||
|
.Where(x => x.CreatedAt >= startOfDay && x.CreatedAt < endOfDay)
|
||||||
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("check-in")]
|
||||||
|
public async Task<ActionResult<CheckInResult>> DoCheckIn([FromBody] string? captchaToken)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var isAvailable = await events.CheckInDailyIsAvailable(currentUser);
|
||||||
|
if (!isAvailable)
|
||||||
|
return BadRequest("Check-in is not available for today.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var needsCaptcha = await events.CheckInDailyDoAskCaptcha(currentUser);
|
||||||
|
return needsCaptcha switch
|
||||||
|
{
|
||||||
|
true when string.IsNullOrWhiteSpace(captchaToken) => StatusCode(423,
|
||||||
|
"Captcha is required for this check-in."),
|
||||||
|
true when !await auth.ValidateCaptcha(captchaToken!) => BadRequest("Invalid captcha token."),
|
||||||
|
_ => await events.CheckInDaily(currentUser)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("calendar")]
|
||||||
|
public async Task<ActionResult<List<DailyEventResponse>>> GetEventCalendar([FromQuery] int? month,
|
||||||
|
[FromQuery] int? year)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var currentDate = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
|
||||||
|
month ??= currentDate.Month;
|
||||||
|
year ??= currentDate.Year;
|
||||||
|
|
||||||
|
if (month is < 1 or > 12) return BadRequest("Invalid month.");
|
||||||
|
if (year < 1) return BadRequest("Invalid year.");
|
||||||
|
|
||||||
|
var calendar = await events.GetEventCalendar(currentUser, month.Value, year.Value);
|
||||||
|
return Ok(calendar);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("actions")]
|
||||||
|
[ProducesResponseType<List<ActionLog>>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
public async Task<ActionResult<List<ActionLog>>> GetActionLogs(
|
||||||
|
[FromQuery] int take = 20,
|
||||||
|
[FromQuery] int offset = 0
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var query = db.ActionLogs
|
||||||
|
.Where(log => log.AccountId == currentUser.Id)
|
||||||
|
.OrderByDescending(log => log.CreatedAt);
|
||||||
|
|
||||||
|
var total = await query.CountAsync();
|
||||||
|
Response.Headers.Append("X-Total", total.ToString());
|
||||||
|
|
||||||
|
var logs = await query
|
||||||
|
.Skip(offset)
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("factors")]
|
||||||
|
public async Task<ActionResult<List<AccountAuthFactor>>> GetAuthFactors()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var factors = await db.AccountAuthFactors
|
||||||
|
.Include(f => f.Account)
|
||||||
|
.Where(f => f.Account.Id == currentUser.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(factors);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AuthFactorRequest
|
||||||
|
{
|
||||||
|
public AccountAuthFactorType Type { get; set; }
|
||||||
|
public string? Secret { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("factors")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<AccountAuthFactor>> CreateAuthFactor([FromBody] AuthFactorRequest request)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
if (await accounts.CheckAuthFactorExists(currentUser, request.Type))
|
||||||
|
return BadRequest($"Auth factor with type {request.Type} is already exists.");
|
||||||
|
|
||||||
|
var factor = await accounts.CreateAuthFactor(currentUser, request.Type, request.Secret);
|
||||||
|
return Ok(factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("factors/{id:guid}/enable")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<AccountAuthFactor>> EnableAuthFactor(Guid id, [FromBody] string? code)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var factor = await db.AccountAuthFactors
|
||||||
|
.Where(f => f.AccountId == currentUser.Id && f.Id == id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (factor is null) return NotFound();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
factor = await accounts.EnableAuthFactor(factor, code);
|
||||||
|
return Ok(factor);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("factors/{id:guid}/disable")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<AccountAuthFactor>> DisableAuthFactor(Guid id)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var factor = await db.AccountAuthFactors
|
||||||
|
.Where(f => f.AccountId == currentUser.Id && f.Id == id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (factor is null) return NotFound();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
factor = await accounts.DisableAuthFactor(factor);
|
||||||
|
return Ok(factor);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("factors/{id:guid}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<AccountAuthFactor>> DeleteAuthFactor(Guid id)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var factor = await db.AccountAuthFactors
|
||||||
|
.Where(f => f.AccountId == currentUser.Id && f.Id == id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (factor is null) return NotFound();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await accounts.DeleteAuthFactor(factor);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AuthorizedDevice
|
||||||
|
{
|
||||||
|
public string? Label { get; set; }
|
||||||
|
public string UserAgent { get; set; } = null!;
|
||||||
|
public string DeviceId { get; set; } = null!;
|
||||||
|
public ChallengePlatform Platform { get; set; }
|
||||||
|
public List<Session> Sessions { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("devices")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<List<AuthorizedDevice>>> GetDevices()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
||||||
|
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
|
||||||
|
|
||||||
|
Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
|
||||||
|
|
||||||
|
// Group sessions by the related DeviceId, then create an AuthorizedDevice for each group.
|
||||||
|
var deviceGroups = await db.AuthSessions
|
||||||
|
.Where(s => s.Account.Id == currentUser.Id)
|
||||||
|
.Include(s => s.Challenge)
|
||||||
|
.GroupBy(s => s.Challenge.DeviceId!)
|
||||||
|
.Select(g => new AuthorizedDevice
|
||||||
|
{
|
||||||
|
DeviceId = g.Key!,
|
||||||
|
UserAgent = g.First(x => x.Challenge.UserAgent != null).Challenge.UserAgent!,
|
||||||
|
Platform = g.First().Challenge.Platform!,
|
||||||
|
Label = g.Where(x => !string.IsNullOrWhiteSpace(x.Label)).Select(x => x.Label).FirstOrDefault(),
|
||||||
|
Sessions = g
|
||||||
|
.OrderByDescending(x => x.LastGrantedAt)
|
||||||
|
.ToList()
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
deviceGroups = deviceGroups
|
||||||
|
.OrderByDescending(s => s.Sessions.First().LastGrantedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Ok(deviceGroups);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("sessions")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<List<Session>>> GetSessions(
|
||||||
|
[FromQuery] int take = 20,
|
||||||
|
[FromQuery] int offset = 0
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
||||||
|
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
|
||||||
|
|
||||||
|
var query = db.AuthSessions
|
||||||
|
.Include(session => session.Account)
|
||||||
|
.Include(session => session.Challenge)
|
||||||
|
.Where(session => session.Account.Id == currentUser.Id);
|
||||||
|
|
||||||
|
var total = await query.CountAsync();
|
||||||
|
Response.Headers.Append("X-Total", total.ToString());
|
||||||
|
Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
|
||||||
|
|
||||||
|
var sessions = await query
|
||||||
|
.OrderByDescending(x => x.LastGrantedAt)
|
||||||
|
.Skip(offset)
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(sessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("sessions/{id:guid}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<Session>> DeleteSession(Guid id)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await accounts.DeleteSession(currentUser, id);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("sessions/current")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<Session>> DeleteCurrentSession()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
||||||
|
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await accounts.DeleteSession(currentUser, currentSession.Id);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("sessions/{id:guid}/label")]
|
||||||
|
public async Task<ActionResult<Session>> UpdateSessionLabel(Guid id, [FromBody] string label)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await accounts.UpdateSessionLabel(currentUser, id, label);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("sessions/current/label")]
|
||||||
|
public async Task<ActionResult<Session>> UpdateCurrentSessionLabel([FromBody] string label)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
||||||
|
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await accounts.UpdateSessionLabel(currentUser, currentSession.Id, label);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("contacts")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<List<AccountContact>>> GetContacts()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var contacts = await db.AccountContacts
|
||||||
|
.Where(c => c.AccountId == currentUser.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Ok(contacts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AccountContactRequest
|
||||||
|
{
|
||||||
|
[Required] public AccountContactType Type { get; set; }
|
||||||
|
[Required] public string Content { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("contacts")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<AccountContact>> CreateContact([FromBody] AccountContactRequest request)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var contact = await accounts.CreateContactMethod(currentUser, request.Type, request.Content);
|
||||||
|
return Ok(contact);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("contacts/{id:guid}/verify")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<AccountContact>> VerifyContact(Guid id)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var contact = await db.AccountContacts
|
||||||
|
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (contact is null) return NotFound();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await accounts.VerifyContactMethod(currentUser, contact);
|
||||||
|
return Ok(contact);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("contacts/{id:guid}/primary")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<AccountContact>> SetPrimaryContact(Guid id)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var contact = await db.AccountContacts
|
||||||
|
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (contact is null) return NotFound();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
contact = await accounts.SetContactMethodPrimary(currentUser, contact);
|
||||||
|
return Ok(contact);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("contacts/{id:guid}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<AccountContact>> DeleteContact(Guid id)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var contact = await db.AccountContacts
|
||||||
|
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (contact is null) return NotFound();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await accounts.DeleteContactMethod(currentUser, contact);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("badges")]
|
||||||
|
[ProducesResponseType<List<Badge>>(StatusCodes.Status200OK)]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<List<Badge>>> GetBadges()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var badges = await db.Badges
|
||||||
|
.Where(b => b.AccountId == currentUser.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
return Ok(badges);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("badges/{id:guid}/active")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<Badge>> ActivateBadge(Guid id)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await accounts.ActiveBadge(currentUser, id);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
339
DysonNetwork.Pass/Account/AccountEventService.cs
Normal file
339
DysonNetwork.Pass/Account/AccountEventService.cs
Normal file
@ -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<Localization.AccountEventResource> localizer
|
||||||
|
)
|
||||||
|
{
|
||||||
|
private static readonly Random Random = new();
|
||||||
|
private const string StatusCacheKey = "AccountStatus_";
|
||||||
|
|
||||||
|
public void PurgeStatusCache(Guid userId)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{StatusCacheKey}{userId}";
|
||||||
|
cache.RemoveAsync(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Status> GetStatus(Guid userId)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{StatusCacheKey}{userId}";
|
||||||
|
var cachedStatus = await cache.GetAsync<Status>(cacheKey);
|
||||||
|
if (cachedStatus is not null)
|
||||||
|
{
|
||||||
|
cachedStatus!.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId);
|
||||||
|
return cachedStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var status = await db.AccountStatuses
|
||||||
|
.Where(e => e.AccountId == userId)
|
||||||
|
.Where(e => e.ClearedAt == null || e.ClearedAt > now)
|
||||||
|
.OrderByDescending(e => e.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
var isOnline = ws.GetAccountIsConnected(userId);
|
||||||
|
if (status is not null)
|
||||||
|
{
|
||||||
|
status.IsOnline = !status.IsInvisible && isOnline;
|
||||||
|
await cache.SetWithGroupsAsync(cacheKey, status, [$"{AccountService.AccountCachePrefix}{status.AccountId}"],
|
||||||
|
TimeSpan.FromMinutes(5));
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOnline)
|
||||||
|
{
|
||||||
|
return new Status
|
||||||
|
{
|
||||||
|
Attitude = StatusAttitude.Neutral,
|
||||||
|
IsOnline = true,
|
||||||
|
IsCustomized = false,
|
||||||
|
Label = "Online",
|
||||||
|
AccountId = userId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Status
|
||||||
|
{
|
||||||
|
Attitude = StatusAttitude.Neutral,
|
||||||
|
IsOnline = false,
|
||||||
|
IsCustomized = false,
|
||||||
|
Label = "Offline",
|
||||||
|
AccountId = userId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Dictionary<Guid, Status>> GetStatuses(List<Guid> userIds)
|
||||||
|
{
|
||||||
|
var results = new Dictionary<Guid, Status>();
|
||||||
|
var cacheMissUserIds = new List<Guid>();
|
||||||
|
|
||||||
|
foreach (var userId in userIds)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{StatusCacheKey}{userId}";
|
||||||
|
var cachedStatus = await cache.GetAsync<Status>(cacheKey);
|
||||||
|
if (cachedStatus != null)
|
||||||
|
{
|
||||||
|
cachedStatus.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId);
|
||||||
|
results[userId] = cachedStatus;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cacheMissUserIds.Add(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cacheMissUserIds.Any())
|
||||||
|
{
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var statusesFromDb = await db.AccountStatuses
|
||||||
|
.Where(e => cacheMissUserIds.Contains(e.AccountId))
|
||||||
|
.Where(e => e.ClearedAt == null || e.ClearedAt > now)
|
||||||
|
.GroupBy(e => e.AccountId)
|
||||||
|
.Select(g => g.OrderByDescending(e => e.CreatedAt).First())
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var foundUserIds = new HashSet<Guid>();
|
||||||
|
|
||||||
|
foreach (var status in statusesFromDb)
|
||||||
|
{
|
||||||
|
var isOnline = ws.GetAccountIsConnected(status.AccountId);
|
||||||
|
status.IsOnline = !status.IsInvisible && isOnline;
|
||||||
|
results[status.AccountId] = status;
|
||||||
|
var cacheKey = $"{StatusCacheKey}{status.AccountId}";
|
||||||
|
await cache.SetAsync(cacheKey, status, TimeSpan.FromMinutes(5));
|
||||||
|
foundUserIds.Add(status.AccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var usersWithoutStatus = cacheMissUserIds.Except(foundUserIds).ToList();
|
||||||
|
if (usersWithoutStatus.Any())
|
||||||
|
{
|
||||||
|
foreach (var userId in usersWithoutStatus)
|
||||||
|
{
|
||||||
|
var isOnline = ws.GetAccountIsConnected(userId);
|
||||||
|
var defaultStatus = new Status
|
||||||
|
{
|
||||||
|
Attitude = StatusAttitude.Neutral,
|
||||||
|
IsOnline = isOnline,
|
||||||
|
IsCustomized = false,
|
||||||
|
Label = isOnline ? "Online" : "Offline",
|
||||||
|
AccountId = userId,
|
||||||
|
};
|
||||||
|
results[userId] = defaultStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Status> CreateStatus(Account user, Status status)
|
||||||
|
{
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
await db.AccountStatuses
|
||||||
|
.Where(x => x.AccountId == user.Id && (x.ClearedAt == null || x.ClearedAt > now))
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ClearedAt, now));
|
||||||
|
|
||||||
|
db.AccountStatuses.Add(status);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ClearStatus(Account user, Status status)
|
||||||
|
{
|
||||||
|
status.ClearedAt = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
db.Update(status);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
PurgeStatusCache(user.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private const int FortuneTipCount = 7; // This will be the max index for each type (positive/negative)
|
||||||
|
private const string CaptchaCacheKey = "CheckInCaptcha_";
|
||||||
|
private const int CaptchaProbabilityPercent = 20;
|
||||||
|
|
||||||
|
public async Task<bool> CheckInDailyDoAskCaptcha(Account user)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{CaptchaCacheKey}{user.Id}";
|
||||||
|
var needsCaptcha = await cache.GetAsync<bool?>(cacheKey);
|
||||||
|
if (needsCaptcha is not null)
|
||||||
|
return needsCaptcha!.Value;
|
||||||
|
|
||||||
|
var result = Random.Next(100) < CaptchaProbabilityPercent;
|
||||||
|
await cache.SetAsync(cacheKey, result, TimeSpan.FromHours(24));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> CheckInDailyIsAvailable(Account user)
|
||||||
|
{
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var lastCheckIn = await db.AccountCheckInResults
|
||||||
|
.Where(x => x.AccountId == user.Id)
|
||||||
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (lastCheckIn == null)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var lastDate = lastCheckIn.CreatedAt.InUtc().Date;
|
||||||
|
var currentDate = now.InUtc().Date;
|
||||||
|
|
||||||
|
return lastDate < currentDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public const string CheckInLockKey = "CheckInLock_";
|
||||||
|
|
||||||
|
public async Task<CheckInResult> CheckInDaily(Account user)
|
||||||
|
{
|
||||||
|
var lockKey = $"{CheckInLockKey}{user.Id}";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lk = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(100));
|
||||||
|
|
||||||
|
if (lk != null)
|
||||||
|
await lk.ReleaseAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore errors from this pre-check
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now try to acquire the lock properly
|
||||||
|
await using var lockObj = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5));
|
||||||
|
if (lockObj is null) throw new InvalidOperationException("Check-in was in progress.");
|
||||||
|
|
||||||
|
var cultureInfo = new CultureInfo(user.Language, false);
|
||||||
|
CultureInfo.CurrentCulture = cultureInfo;
|
||||||
|
CultureInfo.CurrentUICulture = cultureInfo;
|
||||||
|
|
||||||
|
// Generate 2 positive tips
|
||||||
|
var positiveIndices = Enumerable.Range(1, FortuneTipCount)
|
||||||
|
.OrderBy(_ => Random.Next())
|
||||||
|
.Take(2)
|
||||||
|
.ToList();
|
||||||
|
var tips = positiveIndices.Select(index => new FortuneTip
|
||||||
|
{
|
||||||
|
IsPositive = true, Title = localizer[$"FortuneTipPositiveTitle_{index}"].Value,
|
||||||
|
Content = localizer[$"FortuneTipPositiveContent_{index}"].Value
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
// Generate 2 negative tips
|
||||||
|
var negativeIndices = Enumerable.Range(1, FortuneTipCount)
|
||||||
|
.Except(positiveIndices)
|
||||||
|
.OrderBy(_ => Random.Next())
|
||||||
|
.Take(2)
|
||||||
|
.ToList();
|
||||||
|
tips.AddRange(negativeIndices.Select(index => new FortuneTip
|
||||||
|
{
|
||||||
|
IsPositive = false, Title = localizer[$"FortuneTipNegativeTitle_{index}"].Value,
|
||||||
|
Content = localizer[$"FortuneTipNegativeContent_{index}"].Value
|
||||||
|
}));
|
||||||
|
|
||||||
|
var result = new CheckInResult
|
||||||
|
{
|
||||||
|
Tips = tips,
|
||||||
|
Level = (CheckInResultLevel)Random.Next(Enum.GetValues<CheckInResultLevel>().Length),
|
||||||
|
AccountId = user.Id,
|
||||||
|
RewardExperience = 100,
|
||||||
|
RewardPoints = 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant().InUtc().Date;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (result.RewardPoints.HasValue)
|
||||||
|
await payment.CreateTransactionWithAccountAsync(
|
||||||
|
null,
|
||||||
|
user.Id,
|
||||||
|
WalletCurrency.SourcePoint,
|
||||||
|
result.RewardPoints.Value,
|
||||||
|
$"Check-in reward on {now:yyyy/MM/dd}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
result.RewardPoints = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.AccountProfiles
|
||||||
|
.Where(p => p.AccountId == user.Id)
|
||||||
|
.ExecuteUpdateAsync(s =>
|
||||||
|
s.SetProperty(b => b.Experience, b => b.Experience + result.RewardExperience)
|
||||||
|
);
|
||||||
|
db.AccountCheckInResults.Add(result);
|
||||||
|
await db.SaveChangesAsync(); // Don't forget to save changes to the database
|
||||||
|
|
||||||
|
// The lock will be automatically released by the await using statement
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<DailyEventResponse>> GetEventCalendar(Account user, int month, int year = 0,
|
||||||
|
bool replaceInvisible = false)
|
||||||
|
{
|
||||||
|
if (year == 0)
|
||||||
|
year = SystemClock.Instance.GetCurrentInstant().InUtc().Date.Year;
|
||||||
|
|
||||||
|
// Create start and end dates for the specified month
|
||||||
|
var startOfMonth = new LocalDate(year, month, 1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
|
||||||
|
var endOfMonth = startOfMonth.Plus(Duration.FromDays(DateTime.DaysInMonth(year, month)));
|
||||||
|
|
||||||
|
var statuses = await db.AccountStatuses
|
||||||
|
.AsNoTracking()
|
||||||
|
.TagWith("GetEventCalendar_Statuses")
|
||||||
|
.Where(x => x.AccountId == user.Id && x.CreatedAt >= startOfMonth && x.CreatedAt < endOfMonth)
|
||||||
|
.Select(x => new Status
|
||||||
|
{
|
||||||
|
Id = x.Id,
|
||||||
|
Attitude = x.Attitude,
|
||||||
|
IsInvisible = !replaceInvisible && x.IsInvisible,
|
||||||
|
IsNotDisturb = x.IsNotDisturb,
|
||||||
|
Label = x.Label,
|
||||||
|
ClearedAt = x.ClearedAt,
|
||||||
|
AccountId = x.AccountId,
|
||||||
|
CreatedAt = x.CreatedAt
|
||||||
|
})
|
||||||
|
.OrderBy(x => x.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var checkIn = await db.AccountCheckInResults
|
||||||
|
.AsNoTracking()
|
||||||
|
.TagWith("GetEventCalendar_CheckIn")
|
||||||
|
.Where(x => x.AccountId == user.Id && x.CreatedAt >= startOfMonth && x.CreatedAt < endOfMonth)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var dates = Enumerable.Range(1, DateTime.DaysInMonth(year, month))
|
||||||
|
.Select(day => new LocalDate(year, month, day).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var statusesByDate = statuses
|
||||||
|
.GroupBy(s => s.CreatedAt.InUtc().Date)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
var checkInByDate = checkIn
|
||||||
|
.ToDictionary(c => c.CreatedAt.InUtc().Date);
|
||||||
|
|
||||||
|
return dates.Select(date =>
|
||||||
|
{
|
||||||
|
var utcDate = date.InUtc().Date;
|
||||||
|
return new DailyEventResponse
|
||||||
|
{
|
||||||
|
Date = date,
|
||||||
|
CheckInResult = checkInByDate.GetValueOrDefault(utcDate),
|
||||||
|
Statuses = statusesByDate.GetValueOrDefault(utcDate, new List<Status>())
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
}
|
657
DysonNetwork.Pass/Account/AccountService.cs
Normal file
657
DysonNetwork.Pass/Account/AccountService.cs
Normal file
@ -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<NotificationResource> localizer,
|
||||||
|
ICacheService cache,
|
||||||
|
ILogger<AccountService> logger
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public static void SetCultureInfo(Account account)
|
||||||
|
{
|
||||||
|
SetCultureInfo(account.Language);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SetCultureInfo(string? languageCode)
|
||||||
|
{
|
||||||
|
var info = new CultureInfo(languageCode ?? "en-us", false);
|
||||||
|
CultureInfo.CurrentCulture = info;
|
||||||
|
CultureInfo.CurrentUICulture = info;
|
||||||
|
}
|
||||||
|
|
||||||
|
public const string AccountCachePrefix = "account:";
|
||||||
|
|
||||||
|
public async Task PurgeAccountCache(Account account)
|
||||||
|
{
|
||||||
|
await cache.RemoveGroupAsync($"{AccountCachePrefix}{account.Id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Account?> LookupAccount(string probe)
|
||||||
|
{
|
||||||
|
var account = await db.Accounts.Where(a => a.Name == probe).FirstOrDefaultAsync();
|
||||||
|
if (account is not null) return account;
|
||||||
|
|
||||||
|
var contact = await db.AccountContacts
|
||||||
|
.Where(c => c.Content == probe)
|
||||||
|
.Include(c => c.Account)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
return contact?.Account;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Account?> LookupAccountByConnection(string identifier, string provider)
|
||||||
|
{
|
||||||
|
var connection = await db.AccountConnections
|
||||||
|
.Where(c => c.ProvidedIdentifier == identifier && c.Provider == provider)
|
||||||
|
.Include(c => c.Account)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
return connection?.Account;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int?> GetAccountLevel(Guid accountId)
|
||||||
|
{
|
||||||
|
var profile = await db.AccountProfiles
|
||||||
|
.Where(a => a.AccountId == accountId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
return profile?.Level;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Account> CreateAccount(
|
||||||
|
string name,
|
||||||
|
string nick,
|
||||||
|
string email,
|
||||||
|
string? password,
|
||||||
|
string language = "en-US",
|
||||||
|
bool isEmailVerified = false,
|
||||||
|
bool isActivated = false
|
||||||
|
)
|
||||||
|
{
|
||||||
|
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dupeNameCount = await db.Accounts.Where(a => a.Name == name).CountAsync();
|
||||||
|
if (dupeNameCount > 0)
|
||||||
|
throw new InvalidOperationException("Account name has already been taken.");
|
||||||
|
|
||||||
|
var account = new Account
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Nick = nick,
|
||||||
|
Language = language,
|
||||||
|
Contacts = new List<AccountContact>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Type = AccountContactType.Email,
|
||||||
|
Content = email,
|
||||||
|
VerifiedAt = isEmailVerified ? SystemClock.Instance.GetCurrentInstant() : null,
|
||||||
|
IsPrimary = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AuthFactors = password is not null
|
||||||
|
? new List<AccountAuthFactor>
|
||||||
|
{
|
||||||
|
new AccountAuthFactor
|
||||||
|
{
|
||||||
|
Type = AccountAuthFactorType.Password,
|
||||||
|
Secret = password,
|
||||||
|
EnabledAt = SystemClock.Instance.GetCurrentInstant()
|
||||||
|
}.HashSecret()
|
||||||
|
}
|
||||||
|
: [],
|
||||||
|
Profile = new Profile()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isActivated)
|
||||||
|
{
|
||||||
|
account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var defaultGroup = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Key == "default");
|
||||||
|
if (defaultGroup is not null)
|
||||||
|
{
|
||||||
|
db.PermissionGroupMembers.Add(new PermissionGroupMember
|
||||||
|
{
|
||||||
|
Actor = $"user:{account.Id}",
|
||||||
|
Group = defaultGroup
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var spell = await spells.CreateMagicSpell(
|
||||||
|
account,
|
||||||
|
MagicSpellType.AccountActivation,
|
||||||
|
new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "contact_method", account.Contacts.First().Content }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await spells.NotifyMagicSpell(spell, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Accounts.Add(account);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Account> CreateAccount(OidcUserInfo userInfo)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(userInfo.Email))
|
||||||
|
throw new ArgumentException("Email is required for account creation");
|
||||||
|
|
||||||
|
var displayName = !string.IsNullOrEmpty(userInfo.DisplayName)
|
||||||
|
? userInfo.DisplayName
|
||||||
|
: $"{userInfo.FirstName} {userInfo.LastName}".Trim();
|
||||||
|
|
||||||
|
// Generate username from email
|
||||||
|
var username = await uname.GenerateUsernameFromEmailAsync(userInfo.Email);
|
||||||
|
|
||||||
|
return await CreateAccount(
|
||||||
|
username,
|
||||||
|
displayName,
|
||||||
|
userInfo.Email,
|
||||||
|
null,
|
||||||
|
"en-US",
|
||||||
|
userInfo.EmailVerified,
|
||||||
|
userInfo.EmailVerified
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RequestAccountDeletion(Account account)
|
||||||
|
{
|
||||||
|
var spell = await spells.CreateMagicSpell(
|
||||||
|
account,
|
||||||
|
MagicSpellType.AccountRemoval,
|
||||||
|
new Dictionary<string, object>(),
|
||||||
|
SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
|
||||||
|
preventRepeat: true
|
||||||
|
);
|
||||||
|
await spells.NotifyMagicSpell(spell);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RequestPasswordReset(Account account)
|
||||||
|
{
|
||||||
|
var spell = await spells.CreateMagicSpell(
|
||||||
|
account,
|
||||||
|
MagicSpellType.AuthPasswordReset,
|
||||||
|
new Dictionary<string, object>(),
|
||||||
|
SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
|
||||||
|
preventRepeat: true
|
||||||
|
);
|
||||||
|
await spells.NotifyMagicSpell(spell);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> CheckAuthFactorExists(Account account, AccountAuthFactorType type)
|
||||||
|
{
|
||||||
|
var isExists = await db.AccountAuthFactors
|
||||||
|
.Where(x => x.AccountId == account.Id && x.Type == type)
|
||||||
|
.AnyAsync();
|
||||||
|
return isExists;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AccountAuthFactor?> CreateAuthFactor(Account account, AccountAuthFactorType type, string? secret)
|
||||||
|
{
|
||||||
|
AccountAuthFactor? factor = null;
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case AccountAuthFactorType.Password:
|
||||||
|
if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentNullException(nameof(secret));
|
||||||
|
factor = new AccountAuthFactor
|
||||||
|
{
|
||||||
|
Type = AccountAuthFactorType.Password,
|
||||||
|
Trustworthy = 1,
|
||||||
|
AccountId = account.Id,
|
||||||
|
Secret = secret,
|
||||||
|
EnabledAt = SystemClock.Instance.GetCurrentInstant(),
|
||||||
|
}.HashSecret();
|
||||||
|
break;
|
||||||
|
case AccountAuthFactorType.EmailCode:
|
||||||
|
factor = new AccountAuthFactor
|
||||||
|
{
|
||||||
|
Type = AccountAuthFactorType.EmailCode,
|
||||||
|
Trustworthy = 2,
|
||||||
|
EnabledAt = SystemClock.Instance.GetCurrentInstant(),
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case AccountAuthFactorType.InAppCode:
|
||||||
|
factor = new AccountAuthFactor
|
||||||
|
{
|
||||||
|
Type = AccountAuthFactorType.InAppCode,
|
||||||
|
Trustworthy = 1,
|
||||||
|
EnabledAt = SystemClock.Instance.GetCurrentInstant()
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case AccountAuthFactorType.TimedCode:
|
||||||
|
var skOtp = KeyGeneration.GenerateRandomKey(20);
|
||||||
|
var skOtp32 = Base32Encoding.ToString(skOtp);
|
||||||
|
factor = new AccountAuthFactor
|
||||||
|
{
|
||||||
|
Secret = skOtp32,
|
||||||
|
Type = AccountAuthFactorType.TimedCode,
|
||||||
|
Trustworthy = 2,
|
||||||
|
EnabledAt = null, // It needs to be tired once to enable
|
||||||
|
CreatedResponse = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["uri"] = new OtpUri(
|
||||||
|
OtpType.Totp,
|
||||||
|
skOtp32,
|
||||||
|
account.Id.ToString(),
|
||||||
|
"Solar Network"
|
||||||
|
).ToString(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case AccountAuthFactorType.PinCode:
|
||||||
|
if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentNullException(nameof(secret));
|
||||||
|
if (!secret.All(char.IsDigit) || secret.Length != 6)
|
||||||
|
throw new ArgumentException("PIN code must be exactly 6 digits");
|
||||||
|
factor = new AccountAuthFactor
|
||||||
|
{
|
||||||
|
Type = AccountAuthFactorType.PinCode,
|
||||||
|
Trustworthy = 0, // Only for confirming, can't be used for login
|
||||||
|
Secret = secret,
|
||||||
|
EnabledAt = SystemClock.Instance.GetCurrentInstant(),
|
||||||
|
}.HashSecret();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(type), type, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (factor is null) throw new InvalidOperationException("Unable to create auth factor.");
|
||||||
|
factor.AccountId = account.Id;
|
||||||
|
db.AccountAuthFactors.Add(factor);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return factor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AccountAuthFactor> EnableAuthFactor(AccountAuthFactor factor, string? code)
|
||||||
|
{
|
||||||
|
if (factor.EnabledAt is not null) throw new ArgumentException("The factor has been enabled.");
|
||||||
|
if (factor.Type is AccountAuthFactorType.Password or AccountAuthFactorType.TimedCode)
|
||||||
|
{
|
||||||
|
if (code is null || !factor.VerifyPassword(code))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Invalid code, you need to enter the correct code to enable the factor."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factor.EnabledAt = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
db.Update(factor);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return factor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AccountAuthFactor> DisableAuthFactor(AccountAuthFactor factor)
|
||||||
|
{
|
||||||
|
if (factor.EnabledAt is null) throw new ArgumentException("The factor has been disabled.");
|
||||||
|
|
||||||
|
var count = await db.AccountAuthFactors
|
||||||
|
.Where(f => f.AccountId == factor.AccountId && f.EnabledAt != null)
|
||||||
|
.CountAsync();
|
||||||
|
if (count <= 1)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Disabling this auth factor will cause you have no active auth factors.");
|
||||||
|
|
||||||
|
factor.EnabledAt = null;
|
||||||
|
db.Update(factor);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return factor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAuthFactor(AccountAuthFactor factor)
|
||||||
|
{
|
||||||
|
var count = await db.AccountAuthFactors
|
||||||
|
.Where(f => f.AccountId == factor.AccountId)
|
||||||
|
.If(factor.EnabledAt is not null, q => q.Where(f => f.EnabledAt != null))
|
||||||
|
.CountAsync();
|
||||||
|
if (count <= 1)
|
||||||
|
throw new InvalidOperationException("Deleting this auth factor will cause you have no auth factor.");
|
||||||
|
|
||||||
|
db.AccountAuthFactors.Remove(factor);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Send the auth factor verification code to users, for factors like in-app code and email.
|
||||||
|
/// Sometimes it requires a hint, like a part of the user's email address to ensure the user is who own the account.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="account">The owner of the auth factor</param>
|
||||||
|
/// <param name="factor">The auth factor needed to send code</param>
|
||||||
|
/// <param name="hint">The part of the contact method for verification</param>
|
||||||
|
public async Task SendFactorCode(Account account, AccountAuthFactor factor, string? hint = null)
|
||||||
|
{
|
||||||
|
var code = new Random().Next(100000, 999999).ToString("000000");
|
||||||
|
|
||||||
|
switch (factor.Type)
|
||||||
|
{
|
||||||
|
case AccountAuthFactorType.InAppCode:
|
||||||
|
if (await _GetFactorCode(factor) is not null)
|
||||||
|
throw new InvalidOperationException("A factor code has been sent and in active duration.");
|
||||||
|
|
||||||
|
await nty.SendNotification(
|
||||||
|
account,
|
||||||
|
"auth.verification",
|
||||||
|
localizer["AuthCodeTitle"],
|
||||||
|
null,
|
||||||
|
localizer["AuthCodeBody", code],
|
||||||
|
save: true
|
||||||
|
);
|
||||||
|
await _SetFactorCode(factor, code, TimeSpan.FromMinutes(5));
|
||||||
|
break;
|
||||||
|
case AccountAuthFactorType.EmailCode:
|
||||||
|
if (await _GetFactorCode(factor) is not null)
|
||||||
|
throw new InvalidOperationException("A factor code has been sent and in active duration.");
|
||||||
|
|
||||||
|
ArgumentNullException.ThrowIfNull(hint);
|
||||||
|
hint = hint.Replace("@", "").Replace(".", "").Replace("+", "").Replace("%", "");
|
||||||
|
if (string.IsNullOrWhiteSpace(hint))
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Unable to send factor code to #{FactorId} with hint {Hint}, due to invalid hint...",
|
||||||
|
factor.Id,
|
||||||
|
hint
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var contact = await db.AccountContacts
|
||||||
|
.Where(c => c.Type == AccountContactType.Email)
|
||||||
|
.Where(c => c.VerifiedAt != null)
|
||||||
|
.Where(c => EF.Functions.ILike(c.Content, $"%{hint}%"))
|
||||||
|
.Include(c => c.Account)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (contact is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Unable to send factor code to #{FactorId} with hint {Hint}, due to no contact method found according to hint...",
|
||||||
|
factor.Id,
|
||||||
|
hint
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await mailer.SendTemplatedEmailAsync<DysonNetwork.Pass.Pages.Emails.VerificationEmail, VerificationEmailModel>(
|
||||||
|
account.Nick,
|
||||||
|
contact.Content,
|
||||||
|
localizer["VerificationEmail"],
|
||||||
|
new VerificationEmailModel
|
||||||
|
{
|
||||||
|
Name = account.Name,
|
||||||
|
Code = code
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await _SetFactorCode(factor, code, TimeSpan.FromMinutes(30));
|
||||||
|
break;
|
||||||
|
case AccountAuthFactorType.Password:
|
||||||
|
case AccountAuthFactorType.TimedCode:
|
||||||
|
default:
|
||||||
|
// No need to send, such as password etc...
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> VerifyFactorCode(AccountAuthFactor factor, string code)
|
||||||
|
{
|
||||||
|
switch (factor.Type)
|
||||||
|
{
|
||||||
|
case AccountAuthFactorType.EmailCode:
|
||||||
|
case AccountAuthFactorType.InAppCode:
|
||||||
|
var correctCode = await _GetFactorCode(factor);
|
||||||
|
var isCorrect = correctCode is not null &&
|
||||||
|
string.Equals(correctCode, code, StringComparison.OrdinalIgnoreCase);
|
||||||
|
await cache.RemoveAsync($"{AuthFactorCachePrefix}{factor.Id}:code");
|
||||||
|
return isCorrect;
|
||||||
|
case AccountAuthFactorType.Password:
|
||||||
|
case AccountAuthFactorType.TimedCode:
|
||||||
|
default:
|
||||||
|
return factor.VerifyPassword(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string AuthFactorCachePrefix = "authfactor:";
|
||||||
|
|
||||||
|
private async Task _SetFactorCode(AccountAuthFactor factor, string code, TimeSpan expires)
|
||||||
|
{
|
||||||
|
await cache.SetAsync(
|
||||||
|
$"{AuthFactorCachePrefix}{factor.Id}:code",
|
||||||
|
code,
|
||||||
|
expires
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> _GetFactorCode(AccountAuthFactor factor)
|
||||||
|
{
|
||||||
|
return await cache.GetAsync<string?>(
|
||||||
|
$"{AuthFactorCachePrefix}{factor.Id}:code"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Session> UpdateSessionLabel(Account account, Guid sessionId, string label)
|
||||||
|
{
|
||||||
|
var session = await db.AuthSessions
|
||||||
|
.Include(s => s.Challenge)
|
||||||
|
.Where(s => s.Id == sessionId && s.AccountId == account.Id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (session is null) throw new InvalidOperationException("Session was not found.");
|
||||||
|
|
||||||
|
await db.AuthSessions
|
||||||
|
.Include(s => s.Challenge)
|
||||||
|
.Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId)
|
||||||
|
.ExecuteUpdateAsync(p => p.SetProperty(s => s.Label, label));
|
||||||
|
|
||||||
|
var sessions = await db.AuthSessions
|
||||||
|
.Include(s => s.Challenge)
|
||||||
|
.Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId)
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var item in sessions)
|
||||||
|
await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}");
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteSession(Account account, Guid sessionId)
|
||||||
|
{
|
||||||
|
var session = await db.AuthSessions
|
||||||
|
.Include(s => s.Challenge)
|
||||||
|
.Where(s => s.Id == sessionId && s.AccountId == account.Id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (session is null) throw new InvalidOperationException("Session was not found.");
|
||||||
|
|
||||||
|
var sessions = await db.AuthSessions
|
||||||
|
.Include(s => s.Challenge)
|
||||||
|
.Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (session.Challenge.DeviceId is not null)
|
||||||
|
await nty.UnsubscribePushNotifications(session.Challenge.DeviceId);
|
||||||
|
|
||||||
|
// The current session should be included in the sessions' list
|
||||||
|
await db.AuthSessions
|
||||||
|
.Include(s => s.Challenge)
|
||||||
|
.Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId)
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
foreach (var item in sessions)
|
||||||
|
await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AccountContact> CreateContactMethod(Account account, AccountContactType type, string content)
|
||||||
|
{
|
||||||
|
var contact = new AccountContact
|
||||||
|
{
|
||||||
|
Type = type,
|
||||||
|
Content = content,
|
||||||
|
AccountId = account.Id,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.AccountContacts.Add(contact);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task VerifyContactMethod(Account account, AccountContact contact)
|
||||||
|
{
|
||||||
|
var spell = await spells.CreateMagicSpell(
|
||||||
|
account,
|
||||||
|
MagicSpellType.ContactVerification,
|
||||||
|
new Dictionary<string, object> { { "contact_method", contact.Content } },
|
||||||
|
expiredAt: SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
|
||||||
|
preventRepeat: true
|
||||||
|
);
|
||||||
|
await spells.NotifyMagicSpell(spell);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AccountContact> SetContactMethodPrimary(Account account, AccountContact contact)
|
||||||
|
{
|
||||||
|
if (contact.AccountId != account.Id)
|
||||||
|
throw new InvalidOperationException("Contact method does not belong to this account.");
|
||||||
|
if (contact.VerifiedAt is null)
|
||||||
|
throw new InvalidOperationException("Cannot set unverified contact method as primary.");
|
||||||
|
|
||||||
|
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.AccountContacts
|
||||||
|
.Where(c => c.AccountId == account.Id && c.Type == contact.Type)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(x => x.IsPrimary, false));
|
||||||
|
|
||||||
|
contact.IsPrimary = true;
|
||||||
|
db.AccountContacts.Update(contact);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteContactMethod(Account account, AccountContact contact)
|
||||||
|
{
|
||||||
|
if (contact.AccountId != account.Id)
|
||||||
|
throw new InvalidOperationException("Contact method does not belong to this account.");
|
||||||
|
if (contact.IsPrimary)
|
||||||
|
throw new InvalidOperationException("Cannot delete primary contact method.");
|
||||||
|
|
||||||
|
db.AccountContacts.Remove(contact);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This method will grant a badge to the account.
|
||||||
|
/// Shouldn't be exposed to normal user and the user itself.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Badge> GrantBadge(Account account, Badge badge)
|
||||||
|
{
|
||||||
|
badge.AccountId = account.Id;
|
||||||
|
db.Badges.Add(badge);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return badge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This method will revoke a badge from the account.
|
||||||
|
/// Shouldn't be exposed to normal user and the user itself.
|
||||||
|
/// </summary>
|
||||||
|
public async Task RevokeBadge(Account account, Guid badgeId)
|
||||||
|
{
|
||||||
|
var badge = await db.Badges
|
||||||
|
.Where(b => b.AccountId == account.Id && b.Id == badgeId)
|
||||||
|
.OrderByDescending(b => b.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (badge is null) throw new InvalidOperationException("Badge was not found.");
|
||||||
|
|
||||||
|
var profile = await db.AccountProfiles
|
||||||
|
.Where(p => p.AccountId == account.Id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (profile?.ActiveBadge is not null && profile.ActiveBadge.Id == badge.Id)
|
||||||
|
profile.ActiveBadge = null;
|
||||||
|
|
||||||
|
db.Remove(badge);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ActiveBadge(Account account, Guid badgeId)
|
||||||
|
{
|
||||||
|
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var badge = await db.Badges
|
||||||
|
.Where(b => b.AccountId == account.Id && b.Id == badgeId)
|
||||||
|
.OrderByDescending(b => b.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (badge is null) throw new InvalidOperationException("Badge was not found.");
|
||||||
|
|
||||||
|
await db.Badges
|
||||||
|
.Where(b => b.AccountId == account.Id && b.Id != badgeId)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(p => p.ActivatedAt, p => null));
|
||||||
|
|
||||||
|
badge.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
db.Update(badge);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await db.AccountProfiles
|
||||||
|
.Where(p => p.AccountId == account.Id)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(p => p.ActiveBadge, badge.ToReference()));
|
||||||
|
await PurgeAccountCache(account);
|
||||||
|
|
||||||
|
await transaction.CommitAsync();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The maintenance method for server administrator.
|
||||||
|
/// To check every user has an account profile and to create them if it isn't having one.
|
||||||
|
/// </summary>
|
||||||
|
public async Task EnsureAccountProfileCreated()
|
||||||
|
{
|
||||||
|
var accountsId = await db.Accounts.Select(a => a.Id).ToListAsync();
|
||||||
|
var existingId = await db.AccountProfiles.Select(p => p.AccountId).ToListAsync();
|
||||||
|
var missingId = accountsId.Except(existingId).ToList();
|
||||||
|
|
||||||
|
if (missingId.Count != 0)
|
||||||
|
{
|
||||||
|
var newProfiles = missingId.Select(id => new Profile { Id = Guid.NewGuid(), AccountId = id }).ToList();
|
||||||
|
await db.BulkInsertAsync(newProfiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
105
DysonNetwork.Pass/Account/AccountUsernameService.cs
Normal file
105
DysonNetwork.Pass/Account/AccountUsernameService.cs
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for handling username generation and validation
|
||||||
|
/// </summary>
|
||||||
|
public class AccountUsernameService(AppDatabase db)
|
||||||
|
{
|
||||||
|
private readonly Random _random = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a unique username based on the provided base name
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="baseName">The preferred username</param>
|
||||||
|
/// <returns>A unique username</returns>
|
||||||
|
public async Task<string> GenerateUniqueUsernameAsync(string baseName)
|
||||||
|
{
|
||||||
|
// Sanitize the base name
|
||||||
|
var sanitized = SanitizeUsername(baseName);
|
||||||
|
|
||||||
|
// If the base name is empty after sanitization, use a default
|
||||||
|
if (string.IsNullOrEmpty(sanitized))
|
||||||
|
{
|
||||||
|
sanitized = "user";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the sanitized name is available
|
||||||
|
if (!await IsUsernameExistsAsync(sanitized))
|
||||||
|
{
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try up to 10 times with random numbers
|
||||||
|
for (int i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
var suffix = _random.Next(1000, 9999);
|
||||||
|
var candidate = $"{sanitized}{suffix}";
|
||||||
|
|
||||||
|
if (!await IsUsernameExistsAsync(candidate))
|
||||||
|
{
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all attempts fail, use a timestamp
|
||||||
|
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
return $"{sanitized}{timestamp}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sanitizes a username by removing invalid characters and converting to lowercase
|
||||||
|
/// </summary>
|
||||||
|
public string SanitizeUsername(string username)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(username))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
// Replace spaces and special characters with underscores
|
||||||
|
var sanitized = Regex.Replace(username, @"[^a-zA-Z0-9_\-]", "");
|
||||||
|
|
||||||
|
// Convert to lowercase
|
||||||
|
sanitized = sanitized.ToLowerInvariant();
|
||||||
|
|
||||||
|
// Ensure it starts with a letter
|
||||||
|
if (sanitized.Length > 0 && !char.IsLetter(sanitized[0]))
|
||||||
|
{
|
||||||
|
sanitized = "u" + sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate if too long
|
||||||
|
if (sanitized.Length > 30)
|
||||||
|
{
|
||||||
|
sanitized = sanitized[..30];
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a username already exists
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> IsUsernameExistsAsync(string username)
|
||||||
|
{
|
||||||
|
return await db.Accounts.AnyAsync(a => a.Name == username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a username from an email address
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="email">The email address to generate a username from</param>
|
||||||
|
/// <returns>A unique username derived from the email</returns>
|
||||||
|
public async Task<string> GenerateUsernameFromEmailAsync(string email)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(email))
|
||||||
|
return await GenerateUniqueUsernameAsync("user");
|
||||||
|
|
||||||
|
// Extract the local part of the email (before the @)
|
||||||
|
var localPart = email.Split('@')[0];
|
||||||
|
|
||||||
|
// Use the local part as the base for username generation
|
||||||
|
return await GenerateUniqueUsernameAsync(localPart);
|
||||||
|
}
|
||||||
|
}
|
58
DysonNetwork.Pass/Account/ActionLog.cs
Normal file
58
DysonNetwork.Pass/Account/ActionLog.cs
Normal file
@ -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<string, object> Meta { get; set; } = new();
|
||||||
|
[MaxLength(512)] public string? UserAgent { get; set; }
|
||||||
|
[MaxLength(128)] public string? IpAddress { get; set; }
|
||||||
|
public Point? Location { get; set; }
|
||||||
|
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
public Account Account { get; set; } = null!;
|
||||||
|
public Guid? SessionId { get; set; }
|
||||||
|
}
|
46
DysonNetwork.Pass/Account/ActionLogService.cs
Normal file
46
DysonNetwork.Pass/Account/ActionLogService.cs
Normal file
@ -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<string, object> meta)
|
||||||
|
{
|
||||||
|
var log = new ActionLog
|
||||||
|
{
|
||||||
|
Action = action,
|
||||||
|
AccountId = accountId,
|
||||||
|
Meta = meta,
|
||||||
|
};
|
||||||
|
|
||||||
|
fbs.Enqueue(log);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CreateActionLogFromRequest(string action, Dictionary<string, object> meta, HttpRequest request,
|
||||||
|
Account? account = null)
|
||||||
|
{
|
||||||
|
var log = new ActionLog
|
||||||
|
{
|
||||||
|
Action = action,
|
||||||
|
Meta = meta,
|
||||||
|
UserAgent = request.Headers.UserAgent,
|
||||||
|
IpAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
Location = geo.GetPointFromIp(request.HttpContext.Connection.RemoteIpAddress?.ToString())
|
||||||
|
};
|
||||||
|
|
||||||
|
if (request.HttpContext.Items["CurrentUser"] is Account currentUser)
|
||||||
|
log.AccountId = currentUser.Id;
|
||||||
|
else if (account != null)
|
||||||
|
log.AccountId = account.Id;
|
||||||
|
else
|
||||||
|
throw new ArgumentException("No user context was found");
|
||||||
|
|
||||||
|
if (request.HttpContext.Items["CurrentSession"] is Auth.Session currentSession)
|
||||||
|
log.SessionId = currentSession.Id;
|
||||||
|
|
||||||
|
fbs.Enqueue(log);
|
||||||
|
}
|
||||||
|
}
|
47
DysonNetwork.Pass/Account/Badge.cs
Normal file
47
DysonNetwork.Pass/Account/Badge.cs
Normal file
@ -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<string, object> Meta { get; set; } = new();
|
||||||
|
public Instant? ActivatedAt { get; set; }
|
||||||
|
public Instant? ExpiredAt { get; set; }
|
||||||
|
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
[JsonIgnore] public Account Account { get; set; } = null!;
|
||||||
|
|
||||||
|
public BadgeReferenceObject ToReference()
|
||||||
|
{
|
||||||
|
return new BadgeReferenceObject
|
||||||
|
{
|
||||||
|
Id = Id,
|
||||||
|
Type = Type,
|
||||||
|
Label = Label,
|
||||||
|
Caption = Caption,
|
||||||
|
Meta = Meta,
|
||||||
|
ActivatedAt = ActivatedAt,
|
||||||
|
ExpiredAt = ExpiredAt,
|
||||||
|
AccountId = AccountId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BadgeReferenceObject : ModelBase
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public string Type { get; set; } = null!;
|
||||||
|
public string? Label { get; set; }
|
||||||
|
public string? Caption { get; set; }
|
||||||
|
public Dictionary<string, object>? Meta { get; set; }
|
||||||
|
public Instant? ActivatedAt { get; set; }
|
||||||
|
public Instant? ExpiredAt { get; set; }
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
}
|
65
DysonNetwork.Pass/Account/Event.cs
Normal file
65
DysonNetwork.Pass/Account/Event.cs
Normal file
@ -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<FortuneTip> Tips { get; set; } = new List<FortuneTip>();
|
||||||
|
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
public Account Account { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FortuneTip
|
||||||
|
{
|
||||||
|
public bool IsPositive { get; set; }
|
||||||
|
public string Title { get; set; } = null!;
|
||||||
|
public string Content { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This method should not be mapped. Used to generate the daily event calendar.
|
||||||
|
/// </summary>
|
||||||
|
public class DailyEventResponse
|
||||||
|
{
|
||||||
|
public Instant Date { get; set; }
|
||||||
|
public CheckInResult? CheckInResult { get; set; }
|
||||||
|
public ICollection<Status> Statuses { get; set; } = new List<Status>();
|
||||||
|
}
|
30
DysonNetwork.Pass/Account/MagicSpell.cs
Normal file
30
DysonNetwork.Pass/Account/MagicSpell.cs
Normal file
@ -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<string, object> Meta { get; set; } = new();
|
||||||
|
|
||||||
|
public Guid? AccountId { get; set; }
|
||||||
|
public Account? Account { get; set; }
|
||||||
|
}
|
19
DysonNetwork.Pass/Account/MagicSpellController.cs
Normal file
19
DysonNetwork.Pass/Account/MagicSpellController.cs
Normal file
@ -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<ActionResult> ResendMagicSpell(Guid spellId)
|
||||||
|
{
|
||||||
|
var spell = db.MagicSpells.FirstOrDefault(x => x.Id == spellId);
|
||||||
|
if (spell == null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
await sp.NotifyMagicSpell(spell, true);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
}
|
252
DysonNetwork.Pass/Account/MagicSpellService.cs
Normal file
252
DysonNetwork.Pass/Account/MagicSpellService.cs
Normal file
@ -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<MagicSpellService> logger,
|
||||||
|
IStringLocalizer<Localization.EmailResource> localizer
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public async Task<MagicSpell> CreateMagicSpell(
|
||||||
|
Account account,
|
||||||
|
MagicSpellType type,
|
||||||
|
Dictionary<string, object> meta,
|
||||||
|
Instant? expiredAt = null,
|
||||||
|
Instant? affectedAt = null,
|
||||||
|
bool preventRepeat = false
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (preventRepeat)
|
||||||
|
{
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var existingSpell = await db.MagicSpells
|
||||||
|
.Where(s => s.AccountId == account.Id)
|
||||||
|
.Where(s => s.Type == type)
|
||||||
|
.Where(s => s.ExpiresAt == null || s.ExpiresAt > now)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (existingSpell != null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Account already has an active magic spell of type {type}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var spellWord = _GenerateRandomString(128);
|
||||||
|
var spell = new MagicSpell
|
||||||
|
{
|
||||||
|
Spell = spellWord,
|
||||||
|
Type = type,
|
||||||
|
ExpiresAt = expiredAt,
|
||||||
|
AffectedAt = affectedAt,
|
||||||
|
AccountId = account.Id,
|
||||||
|
Meta = meta
|
||||||
|
};
|
||||||
|
|
||||||
|
db.MagicSpells.Add(spell);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return spell;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task NotifyMagicSpell(MagicSpell spell, bool bypassVerify = false)
|
||||||
|
{
|
||||||
|
var contact = await db.AccountContacts
|
||||||
|
.Where(c => c.Account.Id == spell.AccountId)
|
||||||
|
.Where(c => c.Type == AccountContactType.Email)
|
||||||
|
.Where(c => c.VerifiedAt != null || bypassVerify)
|
||||||
|
.OrderByDescending(c => c.IsPrimary)
|
||||||
|
.Include(c => c.Account)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (contact is null) throw new ArgumentException("Account has no contact method that can use");
|
||||||
|
|
||||||
|
var link = $"{configuration.GetValue<string>("BaseUrl")}/spells/{Uri.EscapeDataString(spell.Spell)}";
|
||||||
|
|
||||||
|
logger.LogInformation("Sending magic spell... {Link}", link);
|
||||||
|
|
||||||
|
var accountLanguage = await db.Accounts
|
||||||
|
.Where(a => a.Id == spell.AccountId)
|
||||||
|
.Select(a => a.Language)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
AccountService.SetCultureInfo(accountLanguage);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
switch (spell.Type)
|
||||||
|
{
|
||||||
|
case MagicSpellType.AccountActivation:
|
||||||
|
await email.SendTemplatedEmailAsync<LandingEmail, LandingEmailModel>(
|
||||||
|
contact.Account.Nick,
|
||||||
|
contact.Content,
|
||||||
|
localizer["EmailLandingTitle"],
|
||||||
|
new LandingEmailModel
|
||||||
|
{
|
||||||
|
Name = contact.Account.Name,
|
||||||
|
Link = link
|
||||||
|
}
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case MagicSpellType.AccountRemoval:
|
||||||
|
await email.SendTemplatedEmailAsync<AccountDeletionEmail, AccountDeletionEmailModel>(
|
||||||
|
contact.Account.Nick,
|
||||||
|
contact.Content,
|
||||||
|
localizer["EmailAccountDeletionTitle"],
|
||||||
|
new AccountDeletionEmailModel
|
||||||
|
{
|
||||||
|
Name = contact.Account.Name,
|
||||||
|
Link = link
|
||||||
|
}
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case MagicSpellType.AuthPasswordReset:
|
||||||
|
await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>(
|
||||||
|
contact.Account.Nick,
|
||||||
|
contact.Content,
|
||||||
|
localizer["EmailAccountDeletionTitle"],
|
||||||
|
new PasswordResetEmailModel
|
||||||
|
{
|
||||||
|
Name = contact.Account.Name,
|
||||||
|
Link = link
|
||||||
|
}
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case MagicSpellType.ContactVerification:
|
||||||
|
if (spell.Meta["contact_method"] is not string contactMethod)
|
||||||
|
throw new InvalidOperationException("Contact method is not found.");
|
||||||
|
await email.SendTemplatedEmailAsync<ContactVerificationEmail, ContactVerificationEmailModel>(
|
||||||
|
contact.Account.Nick,
|
||||||
|
contactMethod!,
|
||||||
|
localizer["EmailContactVerificationTitle"],
|
||||||
|
new ContactVerificationEmailModel
|
||||||
|
{
|
||||||
|
Name = contact.Account.Name,
|
||||||
|
Link = link
|
||||||
|
}
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new ArgumentOutOfRangeException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception err)
|
||||||
|
{
|
||||||
|
logger.LogError($"Error sending magic spell (${spell.Spell})... {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ApplyMagicSpell(MagicSpell spell)
|
||||||
|
{
|
||||||
|
switch (spell.Type)
|
||||||
|
{
|
||||||
|
case MagicSpellType.AuthPasswordReset:
|
||||||
|
throw new ArgumentException(
|
||||||
|
"For password reset spell, please use the ApplyPasswordReset method instead."
|
||||||
|
);
|
||||||
|
case MagicSpellType.AccountRemoval:
|
||||||
|
var account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId);
|
||||||
|
if (account is null) break;
|
||||||
|
db.Accounts.Remove(account);
|
||||||
|
break;
|
||||||
|
case MagicSpellType.AccountActivation:
|
||||||
|
var contactMethod = (spell.Meta["contact_method"] as JsonElement? ?? default).ToString();
|
||||||
|
var contact = await
|
||||||
|
db.AccountContacts.FirstOrDefaultAsync(c =>
|
||||||
|
c.Content == contactMethod
|
||||||
|
);
|
||||||
|
if (contact is not null)
|
||||||
|
{
|
||||||
|
contact.VerifiedAt = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
db.Update(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId);
|
||||||
|
if (account is not null)
|
||||||
|
{
|
||||||
|
account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
db.Update(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultGroup = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Key == "default");
|
||||||
|
if (defaultGroup is not null && account is not null)
|
||||||
|
{
|
||||||
|
db.PermissionGroupMembers.Add(new PermissionGroupMember
|
||||||
|
{
|
||||||
|
Actor = $"user:{account.Id}",
|
||||||
|
Group = defaultGroup
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case MagicSpellType.ContactVerification:
|
||||||
|
var verifyContactMethod = (spell.Meta["contact_method"] as JsonElement? ?? default).ToString();
|
||||||
|
var verifyContact = await db.AccountContacts
|
||||||
|
.FirstOrDefaultAsync(c => c.Content == verifyContactMethod);
|
||||||
|
if (verifyContact is not null)
|
||||||
|
{
|
||||||
|
verifyContact.VerifiedAt = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
db.Update(verifyContact);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new ArgumentOutOfRangeException();
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Remove(spell);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ApplyPasswordReset(MagicSpell spell, string newPassword)
|
||||||
|
{
|
||||||
|
if (spell.Type != MagicSpellType.AuthPasswordReset)
|
||||||
|
throw new ArgumentException("This spell is not a password reset spell.");
|
||||||
|
|
||||||
|
var passwordFactor = await db.AccountAuthFactors
|
||||||
|
.Include(f => f.Account)
|
||||||
|
.Where(f => f.Type == AccountAuthFactorType.Password && f.Account.Id == spell.AccountId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (passwordFactor is null)
|
||||||
|
{
|
||||||
|
var account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId);
|
||||||
|
if (account is null) throw new InvalidOperationException("Both account and auth factor was not found.");
|
||||||
|
passwordFactor = new AccountAuthFactor
|
||||||
|
{
|
||||||
|
Type = AccountAuthFactorType.Password,
|
||||||
|
Account = account,
|
||||||
|
Secret = newPassword
|
||||||
|
}.HashSecret();
|
||||||
|
db.AccountAuthFactors.Add(passwordFactor);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
passwordFactor.Secret = newPassword;
|
||||||
|
passwordFactor.HashSecret();
|
||||||
|
db.Update(passwordFactor);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string _GenerateRandomString(int length)
|
||||||
|
{
|
||||||
|
using var rng = RandomNumberGenerator.Create();
|
||||||
|
var randomBytes = new byte[length];
|
||||||
|
rng.GetBytes(randomBytes);
|
||||||
|
|
||||||
|
var base64String = Convert.ToBase64String(randomBytes);
|
||||||
|
|
||||||
|
return base64String.Substring(0, length);
|
||||||
|
}
|
||||||
|
}
|
41
DysonNetwork.Pass/Account/Notification.cs
Normal file
41
DysonNetwork.Pass/Account/Notification.cs
Normal file
@ -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<string, object>? Meta { get; set; }
|
||||||
|
public int Priority { get; set; } = 10;
|
||||||
|
public Instant? ViewedAt { get; set; }
|
||||||
|
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
[JsonIgnore] public Account Account { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum NotificationPushProvider
|
||||||
|
{
|
||||||
|
Apple,
|
||||||
|
Google
|
||||||
|
}
|
||||||
|
|
||||||
|
[Index(nameof(DeviceToken), nameof(DeviceId), nameof(AccountId), IsUnique = true)]
|
||||||
|
public class NotificationPushSubscription : ModelBase
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
[MaxLength(4096)] public string DeviceId { get; set; } = null!;
|
||||||
|
[MaxLength(4096)] public string DeviceToken { get; set; } = null!;
|
||||||
|
public NotificationPushProvider Provider { get; set; }
|
||||||
|
public Instant? LastUsedAt { get; set; }
|
||||||
|
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
[JsonIgnore] public Account Account { get; set; } = null!;
|
||||||
|
}
|
166
DysonNetwork.Pass/Account/NotificationController.cs
Normal file
166
DysonNetwork.Pass/Account/NotificationController.cs
Normal file
@ -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<ActionResult<int>> CountUnreadNotifications()
|
||||||
|
{
|
||||||
|
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
||||||
|
if (currentUserValue is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var count = await db.Notifications
|
||||||
|
.Where(s => s.AccountId == currentUser.Id && s.ViewedAt == null)
|
||||||
|
.CountAsync();
|
||||||
|
return Ok(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<List<Notification>>> ListNotifications(
|
||||||
|
[FromQuery] int offset = 0,
|
||||||
|
// The page size set to 5 is to avoid the client pulled the notification
|
||||||
|
// but didn't render it in the screen-viewable region.
|
||||||
|
[FromQuery] int take = 5
|
||||||
|
)
|
||||||
|
{
|
||||||
|
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
||||||
|
if (currentUserValue is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var totalCount = await db.Notifications
|
||||||
|
.Where(s => s.AccountId == currentUser.Id)
|
||||||
|
.CountAsync();
|
||||||
|
var notifications = await db.Notifications
|
||||||
|
.Where(s => s.AccountId == currentUser.Id)
|
||||||
|
.OrderByDescending(e => e.CreatedAt)
|
||||||
|
.Skip(offset)
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
Response.Headers["X-Total"] = totalCount.ToString();
|
||||||
|
await nty.MarkNotificationsViewed(notifications);
|
||||||
|
|
||||||
|
return Ok(notifications);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PushNotificationSubscribeRequest
|
||||||
|
{
|
||||||
|
[MaxLength(4096)] public string DeviceToken { get; set; } = null!;
|
||||||
|
public NotificationPushProvider Provider { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("subscription")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<NotificationPushSubscription>> SubscribeToPushNotification(
|
||||||
|
[FromBody] PushNotificationSubscribeRequest request
|
||||||
|
)
|
||||||
|
{
|
||||||
|
HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue);
|
||||||
|
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
||||||
|
var currentUser = currentUserValue as Account;
|
||||||
|
if (currentUser == null) return Unauthorized();
|
||||||
|
var currentSession = currentSessionValue as Session;
|
||||||
|
if (currentSession == null) return Unauthorized();
|
||||||
|
|
||||||
|
var result =
|
||||||
|
await nty.SubscribePushNotification(currentUser, request.Provider, currentSession.Challenge.DeviceId!,
|
||||||
|
request.DeviceToken);
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("subscription")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<int>> UnsubscribeFromPushNotification()
|
||||||
|
{
|
||||||
|
HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue);
|
||||||
|
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
||||||
|
var currentUser = currentUserValue as Account;
|
||||||
|
if (currentUser == null) return Unauthorized();
|
||||||
|
var currentSession = currentSessionValue as Session;
|
||||||
|
if (currentSession == null) return Unauthorized();
|
||||||
|
|
||||||
|
var affectedRows = await db.NotificationPushSubscriptions
|
||||||
|
.Where(s =>
|
||||||
|
s.AccountId == currentUser.Id &&
|
||||||
|
s.DeviceId == currentSession.Challenge.DeviceId
|
||||||
|
).ExecuteDeleteAsync();
|
||||||
|
return Ok(affectedRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NotificationRequest
|
||||||
|
{
|
||||||
|
[Required] [MaxLength(1024)] public string Topic { get; set; } = null!;
|
||||||
|
[Required] [MaxLength(1024)] public string Title { get; set; } = null!;
|
||||||
|
[MaxLength(2048)] public string? Subtitle { get; set; }
|
||||||
|
[Required] [MaxLength(4096)] public string Content { get; set; } = null!;
|
||||||
|
public Dictionary<string, object>? Meta { get; set; }
|
||||||
|
public int Priority { get; set; } = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("broadcast")]
|
||||||
|
[Authorize]
|
||||||
|
[RequiredPermission("global", "notifications.broadcast")]
|
||||||
|
public async Task<ActionResult> BroadcastNotification(
|
||||||
|
[FromBody] NotificationRequest request,
|
||||||
|
[FromQuery] bool save = false
|
||||||
|
)
|
||||||
|
{
|
||||||
|
await nty.BroadcastNotification(
|
||||||
|
new Notification
|
||||||
|
{
|
||||||
|
CreatedAt = SystemClock.Instance.GetCurrentInstant(),
|
||||||
|
UpdatedAt = SystemClock.Instance.GetCurrentInstant(),
|
||||||
|
Topic = request.Topic,
|
||||||
|
Title = request.Title,
|
||||||
|
Subtitle = request.Subtitle,
|
||||||
|
Content = request.Content,
|
||||||
|
Meta = request.Meta,
|
||||||
|
Priority = request.Priority,
|
||||||
|
},
|
||||||
|
save
|
||||||
|
);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NotificationWithAimRequest : NotificationRequest
|
||||||
|
{
|
||||||
|
[Required] public List<Guid> AccountId { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("send")]
|
||||||
|
[Authorize]
|
||||||
|
[RequiredPermission("global", "notifications.send")]
|
||||||
|
public async Task<ActionResult> SendNotification(
|
||||||
|
[FromBody] NotificationWithAimRequest request,
|
||||||
|
[FromQuery] bool save = false
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var accounts = await db.Accounts.Where(a => request.AccountId.Contains(a.Id)).ToListAsync();
|
||||||
|
await nty.SendNotificationBatch(
|
||||||
|
new Notification
|
||||||
|
{
|
||||||
|
CreatedAt = SystemClock.Instance.GetCurrentInstant(),
|
||||||
|
UpdatedAt = SystemClock.Instance.GetCurrentInstant(),
|
||||||
|
Topic = request.Topic,
|
||||||
|
Title = request.Title,
|
||||||
|
Subtitle = request.Subtitle,
|
||||||
|
Content = request.Content,
|
||||||
|
Meta = request.Meta,
|
||||||
|
},
|
||||||
|
accounts,
|
||||||
|
save
|
||||||
|
);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
}
|
308
DysonNetwork.Pass/Account/NotificationService.cs
Normal file
308
DysonNetwork.Pass/Account/NotificationService.cs
Normal file
@ -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<NotificationPushSubscription> SubscribePushNotification(
|
||||||
|
Account account,
|
||||||
|
NotificationPushProvider provider,
|
||||||
|
string deviceId,
|
||||||
|
string deviceToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
|
||||||
|
// First check if a matching subscription exists
|
||||||
|
var existingSubscription = await db.NotificationPushSubscriptions
|
||||||
|
.Where(s => s.AccountId == account.Id)
|
||||||
|
.Where(s => s.DeviceId == deviceId || s.DeviceToken == deviceToken)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (existingSubscription is not null)
|
||||||
|
{
|
||||||
|
// Update the existing subscription directly in the database
|
||||||
|
await db.NotificationPushSubscriptions
|
||||||
|
.Where(s => s.Id == existingSubscription.Id)
|
||||||
|
.ExecuteUpdateAsync(setters => setters
|
||||||
|
.SetProperty(s => s.DeviceId, deviceId)
|
||||||
|
.SetProperty(s => s.DeviceToken, deviceToken)
|
||||||
|
.SetProperty(s => s.UpdatedAt, now));
|
||||||
|
|
||||||
|
// Return the updated subscription
|
||||||
|
existingSubscription.DeviceId = deviceId;
|
||||||
|
existingSubscription.DeviceToken = deviceToken;
|
||||||
|
existingSubscription.UpdatedAt = now;
|
||||||
|
return existingSubscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscription = new NotificationPushSubscription
|
||||||
|
{
|
||||||
|
DeviceId = deviceId,
|
||||||
|
DeviceToken = deviceToken,
|
||||||
|
Provider = provider,
|
||||||
|
AccountId = account.Id,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.NotificationPushSubscriptions.Add(subscription);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Notification> SendNotification(
|
||||||
|
Account account,
|
||||||
|
string topic,
|
||||||
|
string? title = null,
|
||||||
|
string? subtitle = null,
|
||||||
|
string? content = null,
|
||||||
|
Dictionary<string, object>? meta = null,
|
||||||
|
string? actionUri = null,
|
||||||
|
bool isSilent = false,
|
||||||
|
bool save = true
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (title is null && subtitle is null && content is null)
|
||||||
|
throw new ArgumentException("Unable to send notification that completely empty.");
|
||||||
|
|
||||||
|
meta ??= new Dictionary<string, object>();
|
||||||
|
if (actionUri is not null) meta["action_uri"] = actionUri;
|
||||||
|
|
||||||
|
var notification = new Notification
|
||||||
|
{
|
||||||
|
Topic = topic,
|
||||||
|
Title = title,
|
||||||
|
Subtitle = subtitle,
|
||||||
|
Content = content,
|
||||||
|
Meta = meta,
|
||||||
|
AccountId = account.Id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (save)
|
||||||
|
{
|
||||||
|
db.Add(notification);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSilent) _ = DeliveryNotification(notification);
|
||||||
|
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeliveryNotification(Notification notification)
|
||||||
|
{
|
||||||
|
ws.SendPacketToAccount(notification.AccountId, new WebSocketPacket
|
||||||
|
{
|
||||||
|
Type = "notifications.new",
|
||||||
|
Data = notification
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pushing the notification
|
||||||
|
var subscribers = await db.NotificationPushSubscriptions
|
||||||
|
.Where(s => s.AccountId == notification.AccountId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
await _PushNotification(notification, subscribers);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MarkNotificationsViewed(ICollection<Notification> notifications)
|
||||||
|
{
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var id = notifications.Where(n => n.ViewedAt == null).Select(n => n.Id).ToList();
|
||||||
|
if (id.Count == 0) return;
|
||||||
|
|
||||||
|
await db.Notifications
|
||||||
|
.Where(n => id.Contains(n.Id))
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task BroadcastNotification(Notification notification, bool save = false)
|
||||||
|
{
|
||||||
|
var accounts = await db.Accounts.ToListAsync();
|
||||||
|
|
||||||
|
if (save)
|
||||||
|
{
|
||||||
|
var notifications = accounts.Select(x =>
|
||||||
|
{
|
||||||
|
var newNotification = new Notification
|
||||||
|
{
|
||||||
|
Topic = notification.Topic,
|
||||||
|
Title = notification.Title,
|
||||||
|
Subtitle = notification.Subtitle,
|
||||||
|
Content = notification.Content,
|
||||||
|
Meta = notification.Meta,
|
||||||
|
Priority = notification.Priority,
|
||||||
|
Account = x,
|
||||||
|
AccountId = x.Id
|
||||||
|
};
|
||||||
|
return newNotification;
|
||||||
|
}).ToList();
|
||||||
|
await db.BulkInsertAsync(notifications);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var account in accounts)
|
||||||
|
{
|
||||||
|
notification.Account = account;
|
||||||
|
notification.AccountId = account.Id;
|
||||||
|
ws.SendPacketToAccount(account.Id, new WebSocketPacket
|
||||||
|
{
|
||||||
|
Type = "notifications.new",
|
||||||
|
Data = notification
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscribers = await db.NotificationPushSubscriptions
|
||||||
|
.ToListAsync();
|
||||||
|
await _PushNotification(notification, subscribers);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendNotificationBatch(Notification notification, List<Account> accounts, bool save = false)
|
||||||
|
{
|
||||||
|
if (save)
|
||||||
|
{
|
||||||
|
var notifications = accounts.Select(x =>
|
||||||
|
{
|
||||||
|
var newNotification = new Notification
|
||||||
|
{
|
||||||
|
Topic = notification.Topic,
|
||||||
|
Title = notification.Title,
|
||||||
|
Subtitle = notification.Subtitle,
|
||||||
|
Content = notification.Content,
|
||||||
|
Meta = notification.Meta,
|
||||||
|
Priority = notification.Priority,
|
||||||
|
Account = x,
|
||||||
|
AccountId = x.Id
|
||||||
|
};
|
||||||
|
return newNotification;
|
||||||
|
}).ToList();
|
||||||
|
await db.BulkInsertAsync(notifications);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var account in accounts)
|
||||||
|
{
|
||||||
|
notification.Account = account;
|
||||||
|
notification.AccountId = account.Id;
|
||||||
|
ws.SendPacketToAccount(account.Id, new WebSocketPacket
|
||||||
|
{
|
||||||
|
Type = "notifications.new",
|
||||||
|
Data = notification
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var accountsId = accounts.Select(x => x.Id).ToList();
|
||||||
|
var subscribers = await db.NotificationPushSubscriptions
|
||||||
|
.Where(s => accountsId.Contains(s.AccountId))
|
||||||
|
.ToListAsync();
|
||||||
|
await _PushNotification(notification, subscribers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Dictionary<string, object>> _BuildNotificationPayload(Notification notification,
|
||||||
|
IEnumerable<NotificationPushSubscription> subscriptions)
|
||||||
|
{
|
||||||
|
var subDict = subscriptions
|
||||||
|
.GroupBy(x => x.Provider)
|
||||||
|
.ToDictionary(x => x.Key, x => x.ToList());
|
||||||
|
|
||||||
|
var notifications = subDict.Select(value =>
|
||||||
|
{
|
||||||
|
var platformCode = value.Key switch
|
||||||
|
{
|
||||||
|
NotificationPushProvider.Apple => 1,
|
||||||
|
NotificationPushProvider.Google => 2,
|
||||||
|
_ => throw new InvalidOperationException($"Unknown push provider: {value.Key}")
|
||||||
|
};
|
||||||
|
|
||||||
|
var tokens = value.Value.Select(x => x.DeviceToken).ToList();
|
||||||
|
return _BuildNotificationPayload(notification, platformCode, tokens);
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return notifications.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, object> _BuildNotificationPayload(Notification notification, int platformCode,
|
||||||
|
IEnumerable<string> deviceTokens)
|
||||||
|
{
|
||||||
|
var alertDict = new Dictionary<string, object>();
|
||||||
|
var dict = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["notif_id"] = notification.Id.ToString(),
|
||||||
|
["apns_id"] = notification.Id.ToString(),
|
||||||
|
["topic"] = _notifyTopic,
|
||||||
|
["tokens"] = deviceTokens,
|
||||||
|
["data"] = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["type"] = notification.Topic,
|
||||||
|
["meta"] = notification.Meta ?? new Dictionary<string, object>(),
|
||||||
|
},
|
||||||
|
["mutable_content"] = true,
|
||||||
|
["priority"] = notification.Priority >= 5 ? "high" : "normal",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(notification.Title))
|
||||||
|
{
|
||||||
|
dict["title"] = notification.Title;
|
||||||
|
alertDict["title"] = notification.Title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(notification.Content))
|
||||||
|
{
|
||||||
|
dict["message"] = notification.Content;
|
||||||
|
alertDict["body"] = notification.Content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(notification.Subtitle))
|
||||||
|
{
|
||||||
|
dict["message"] = $"{notification.Subtitle}\n{dict["message"]}";
|
||||||
|
alertDict["subtitle"] = notification.Subtitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.Priority >= 5)
|
||||||
|
dict["name"] = "default";
|
||||||
|
|
||||||
|
dict["platform"] = platformCode;
|
||||||
|
dict["alert"] = alertDict;
|
||||||
|
|
||||||
|
return dict;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task _PushNotification(Notification notification,
|
||||||
|
IEnumerable<NotificationPushSubscription> subscriptions)
|
||||||
|
{
|
||||||
|
var subList = subscriptions.ToList();
|
||||||
|
if (subList.Count == 0) return;
|
||||||
|
|
||||||
|
var requestDict = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["notifications"] = _BuildNotificationPayload(notification, subList)
|
||||||
|
};
|
||||||
|
|
||||||
|
var client = httpFactory.CreateClient();
|
||||||
|
client.BaseAddress = _notifyEndpoint;
|
||||||
|
var request = await client.PostAsync("/push", new StringContent(
|
||||||
|
JsonSerializer.Serialize(requestDict),
|
||||||
|
Encoding.UTF8,
|
||||||
|
"application/json"
|
||||||
|
));
|
||||||
|
request.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
}
|
22
DysonNetwork.Pass/Account/Relationship.cs
Normal file
22
DysonNetwork.Pass/Account/Relationship.cs
Normal file
@ -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;
|
||||||
|
}
|
253
DysonNetwork.Pass/Account/RelationshipController.cs
Normal file
253
DysonNetwork.Pass/Account/RelationshipController.cs
Normal file
@ -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<ActionResult<List<Relationship>>> ListRelationships([FromQuery] int offset = 0,
|
||||||
|
[FromQuery] int take = 20)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
var userId = currentUser.Id;
|
||||||
|
|
||||||
|
var query = db.AccountRelationships.AsQueryable()
|
||||||
|
.Where(r => r.RelatedId == userId);
|
||||||
|
var totalCount = await query.CountAsync();
|
||||||
|
var relationships = await query
|
||||||
|
.Include(r => r.Related)
|
||||||
|
.Include(r => r.Related.Profile)
|
||||||
|
.Include(r => r.Account)
|
||||||
|
.Include(r => r.Account.Profile)
|
||||||
|
.Skip(offset)
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var statuses = await db.AccountRelationships
|
||||||
|
.Where(r => r.AccountId == userId)
|
||||||
|
.ToDictionaryAsync(r => r.RelatedId);
|
||||||
|
foreach (var relationship in relationships)
|
||||||
|
if (statuses.TryGetValue(relationship.RelatedId, out var status))
|
||||||
|
relationship.Status = status.Status;
|
||||||
|
|
||||||
|
Response.Headers["X-Total"] = totalCount.ToString();
|
||||||
|
|
||||||
|
return relationships;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("requests")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<List<Relationship>>> ListSentRequests()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var relationships = await db.AccountRelationships
|
||||||
|
.Where(r => r.AccountId == currentUser.Id && r.Status == RelationshipStatus.Pending)
|
||||||
|
.Include(r => r.Related)
|
||||||
|
.Include(r => r.Related.Profile)
|
||||||
|
.Include(r => r.Account)
|
||||||
|
.Include(r => r.Account.Profile)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return relationships;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RelationshipRequest
|
||||||
|
{
|
||||||
|
[Required] public RelationshipStatus Status { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{userId:guid}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<Relationship>> CreateRelationship(Guid userId,
|
||||||
|
[FromBody] RelationshipRequest request)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var relatedUser = await db.Accounts.FindAsync(userId);
|
||||||
|
if (relatedUser is null) return NotFound("Account was not found.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var relationship = await rels.CreateRelationship(
|
||||||
|
currentUser, relatedUser, request.Status
|
||||||
|
);
|
||||||
|
return relationship;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException err)
|
||||||
|
{
|
||||||
|
return BadRequest(err.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{userId:guid}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<Relationship>> UpdateRelationship(Guid userId,
|
||||||
|
[FromBody] RelationshipRequest request)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var relationship = await rels.UpdateRelationship(currentUser.Id, userId, request.Status);
|
||||||
|
return relationship;
|
||||||
|
}
|
||||||
|
catch (ArgumentException err)
|
||||||
|
{
|
||||||
|
return NotFound(err.Message);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException err)
|
||||||
|
{
|
||||||
|
return BadRequest(err.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{userId:guid}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<Relationship>> GetRelationship(Guid userId)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
||||||
|
var queries = db.AccountRelationships.AsQueryable()
|
||||||
|
.Where(r => r.AccountId == currentUser.Id && r.RelatedId == userId)
|
||||||
|
.Where(r => r.ExpiredAt == null || r.ExpiredAt > now);
|
||||||
|
var relationship = await queries
|
||||||
|
.Include(r => r.Related)
|
||||||
|
.Include(r => r.Related.Profile)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (relationship is null) return NotFound();
|
||||||
|
|
||||||
|
relationship.Account = currentUser;
|
||||||
|
return Ok(relationship);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{userId:guid}/friends")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<Relationship>> SendFriendRequest(Guid userId)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var relatedUser = await db.Accounts.FindAsync(userId);
|
||||||
|
if (relatedUser is null) return NotFound("Account was not found.");
|
||||||
|
|
||||||
|
var existing = await db.AccountRelationships.FirstOrDefaultAsync(r =>
|
||||||
|
(r.AccountId == currentUser.Id && r.RelatedId == userId) ||
|
||||||
|
(r.AccountId == userId && r.RelatedId == currentUser.Id));
|
||||||
|
if (existing != null) return BadRequest("Relationship already exists.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var relationship = await rels.SendFriendRequest(currentUser, relatedUser);
|
||||||
|
return relationship;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException err)
|
||||||
|
{
|
||||||
|
return BadRequest(err.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{userId:guid}/friends")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult> DeleteFriendRequest(Guid userId)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await rels.DeleteFriendRequest(currentUser.Id, userId);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (ArgumentException err)
|
||||||
|
{
|
||||||
|
return NotFound(err.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{userId:guid}/friends/accept")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<Relationship>> AcceptFriendRequest(Guid userId)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var relationship = await rels.GetRelationship(userId, currentUser.Id, RelationshipStatus.Pending);
|
||||||
|
if (relationship is null) return NotFound("Friend request was not found.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
relationship = await rels.AcceptFriendRelationship(relationship);
|
||||||
|
return relationship;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException err)
|
||||||
|
{
|
||||||
|
return BadRequest(err.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{userId:guid}/friends/decline")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<Relationship>> DeclineFriendRequest(Guid userId)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var relationship = await rels.GetRelationship(userId, currentUser.Id, RelationshipStatus.Pending);
|
||||||
|
if (relationship is null) return NotFound("Friend request was not found.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
relationship = await rels.AcceptFriendRelationship(relationship, status: RelationshipStatus.Blocked);
|
||||||
|
return relationship;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException err)
|
||||||
|
{
|
||||||
|
return BadRequest(err.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{userId:guid}/block")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<Relationship>> BlockUser(Guid userId)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var relatedUser = await db.Accounts.FindAsync(userId);
|
||||||
|
if (relatedUser is null) return NotFound("Account was not found.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var relationship = await rels.BlockAccount(currentUser, relatedUser);
|
||||||
|
return relationship;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException err)
|
||||||
|
{
|
||||||
|
return BadRequest(err.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{userId:guid}/block")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<Relationship>> UnblockUser(Guid userId)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var relatedUser = await db.Accounts.FindAsync(userId);
|
||||||
|
if (relatedUser is null) return NotFound("Account was not found.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var relationship = await rels.UnblockAccount(currentUser, relatedUser);
|
||||||
|
return relationship;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException err)
|
||||||
|
{
|
||||||
|
return BadRequest(err.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
207
DysonNetwork.Pass/Account/RelationshipService.cs
Normal file
207
DysonNetwork.Pass/Account/RelationshipService.cs
Normal file
@ -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<bool> HasExistingRelationship(Guid accountId, Guid relatedId)
|
||||||
|
{
|
||||||
|
var count = await db.AccountRelationships
|
||||||
|
.Where(r => (r.AccountId == accountId && r.RelatedId == relatedId) ||
|
||||||
|
(r.AccountId == relatedId && r.AccountId == accountId))
|
||||||
|
.CountAsync();
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Relationship?> GetRelationship(
|
||||||
|
Guid accountId,
|
||||||
|
Guid relatedId,
|
||||||
|
RelationshipStatus? status = null,
|
||||||
|
bool ignoreExpired = false
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
||||||
|
var queries = db.AccountRelationships.AsQueryable()
|
||||||
|
.Where(r => r.AccountId == accountId && r.RelatedId == relatedId);
|
||||||
|
if (!ignoreExpired) queries = queries.Where(r => r.ExpiredAt == null || r.ExpiredAt > now);
|
||||||
|
if (status is not null) queries = queries.Where(r => r.Status == status);
|
||||||
|
var relationship = await queries.FirstOrDefaultAsync();
|
||||||
|
return relationship;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Relationship> CreateRelationship(Account sender, Account target, RelationshipStatus status)
|
||||||
|
{
|
||||||
|
if (status == RelationshipStatus.Pending)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Cannot create relationship with pending status, use SendFriendRequest instead.");
|
||||||
|
if (await HasExistingRelationship(sender.Id, target.Id))
|
||||||
|
throw new InvalidOperationException("Found existing relationship between you and target user.");
|
||||||
|
|
||||||
|
var relationship = new Relationship
|
||||||
|
{
|
||||||
|
AccountId = sender.Id,
|
||||||
|
RelatedId = target.Id,
|
||||||
|
Status = status
|
||||||
|
};
|
||||||
|
|
||||||
|
db.AccountRelationships.Add(relationship);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await PurgeRelationshipCache(sender.Id, target.Id);
|
||||||
|
|
||||||
|
return relationship;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Relationship> BlockAccount(Account sender, Account target)
|
||||||
|
{
|
||||||
|
if (await HasExistingRelationship(sender.Id, target.Id))
|
||||||
|
return await UpdateRelationship(sender.Id, target.Id, RelationshipStatus.Blocked);
|
||||||
|
return await CreateRelationship(sender, target, RelationshipStatus.Blocked);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Relationship> UnblockAccount(Account sender, Account target)
|
||||||
|
{
|
||||||
|
var relationship = await GetRelationship(sender.Id, target.Id, RelationshipStatus.Blocked);
|
||||||
|
if (relationship is null) throw new ArgumentException("There is no relationship between you and the user.");
|
||||||
|
db.Remove(relationship);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await PurgeRelationshipCache(sender.Id, target.Id);
|
||||||
|
|
||||||
|
return relationship;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Relationship> SendFriendRequest(Account sender, Account target)
|
||||||
|
{
|
||||||
|
if (await HasExistingRelationship(sender.Id, target.Id))
|
||||||
|
throw new InvalidOperationException("Found existing relationship between you and target user.");
|
||||||
|
|
||||||
|
var relationship = new Relationship
|
||||||
|
{
|
||||||
|
AccountId = sender.Id,
|
||||||
|
RelatedId = target.Id,
|
||||||
|
Status = RelationshipStatus.Pending,
|
||||||
|
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(7))
|
||||||
|
};
|
||||||
|
|
||||||
|
db.AccountRelationships.Add(relationship);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return relationship;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteFriendRequest(Guid accountId, Guid relatedId)
|
||||||
|
{
|
||||||
|
var relationship = await GetRelationship(accountId, relatedId, RelationshipStatus.Pending);
|
||||||
|
if (relationship is null) throw new ArgumentException("Friend request was not found.");
|
||||||
|
|
||||||
|
await db.AccountRelationships
|
||||||
|
.Where(r => r.AccountId == accountId && r.RelatedId == relatedId && r.Status == RelationshipStatus.Pending)
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Relationship> AcceptFriendRelationship(
|
||||||
|
Relationship relationship,
|
||||||
|
RelationshipStatus status = RelationshipStatus.Friends
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (relationship.Status != RelationshipStatus.Pending)
|
||||||
|
throw new ArgumentException("Cannot accept friend request that not in pending status.");
|
||||||
|
if (status == RelationshipStatus.Pending)
|
||||||
|
throw new ArgumentException("Cannot accept friend request by setting the new status to pending.");
|
||||||
|
|
||||||
|
// Whatever the receiver decides to apply which status to the relationship,
|
||||||
|
// the sender should always see the user as a friend since the sender ask for it
|
||||||
|
relationship.Status = RelationshipStatus.Friends;
|
||||||
|
relationship.ExpiredAt = null;
|
||||||
|
db.Update(relationship);
|
||||||
|
|
||||||
|
var relationshipBackward = new Relationship
|
||||||
|
{
|
||||||
|
AccountId = relationship.RelatedId,
|
||||||
|
RelatedId = relationship.AccountId,
|
||||||
|
Status = status
|
||||||
|
};
|
||||||
|
db.AccountRelationships.Add(relationshipBackward);
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
|
||||||
|
|
||||||
|
return relationshipBackward;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Relationship> UpdateRelationship(Guid accountId, Guid relatedId, RelationshipStatus status)
|
||||||
|
{
|
||||||
|
var relationship = await GetRelationship(accountId, relatedId);
|
||||||
|
if (relationship is null) throw new ArgumentException("There is no relationship between you and the user.");
|
||||||
|
if (relationship.Status == status) return relationship;
|
||||||
|
relationship.Status = status;
|
||||||
|
db.Update(relationship);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await PurgeRelationshipCache(accountId, relatedId);
|
||||||
|
|
||||||
|
return relationship;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Guid>> ListAccountFriends(Account account)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{UserFriendsCacheKeyPrefix}{account.Id}";
|
||||||
|
var friends = await cache.GetAsync<List<Guid>>(cacheKey);
|
||||||
|
|
||||||
|
if (friends == null)
|
||||||
|
{
|
||||||
|
friends = await db.AccountRelationships
|
||||||
|
.Where(r => r.RelatedId == account.Id)
|
||||||
|
.Where(r => r.Status == RelationshipStatus.Friends)
|
||||||
|
.Select(r => r.AccountId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
await cache.SetAsync(cacheKey, friends, TimeSpan.FromHours(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return friends ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Guid>> ListAccountBlocked(Account account)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{UserBlockedCacheKeyPrefix}{account.Id}";
|
||||||
|
var blocked = await cache.GetAsync<List<Guid>>(cacheKey);
|
||||||
|
|
||||||
|
if (blocked == null)
|
||||||
|
{
|
||||||
|
blocked = await db.AccountRelationships
|
||||||
|
.Where(r => r.RelatedId == account.Id)
|
||||||
|
.Where(r => r.Status == RelationshipStatus.Blocked)
|
||||||
|
.Select(r => r.AccountId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
await cache.SetAsync(cacheKey, blocked, TimeSpan.FromHours(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocked ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> HasRelationshipWithStatus(Guid accountId, Guid relatedId,
|
||||||
|
RelationshipStatus status = RelationshipStatus.Friends)
|
||||||
|
{
|
||||||
|
var relationship = await GetRelationship(accountId, relatedId, status);
|
||||||
|
return relationship is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PurgeRelationshipCache(Guid accountId, Guid relatedId)
|
||||||
|
{
|
||||||
|
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}");
|
||||||
|
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}");
|
||||||
|
await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{accountId}");
|
||||||
|
await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{relatedId}");
|
||||||
|
}
|
||||||
|
}
|
25
DysonNetwork.Pass/Account/VerificationMark.cs
Normal file
25
DysonNetwork.Pass/Account/VerificationMark.cs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The verification info of a resource
|
||||||
|
/// stands, for it is really an individual or organization or a company in the real world.
|
||||||
|
/// Besides, it can also be use for mark parody or fake.
|
||||||
|
/// </summary>
|
||||||
|
public class VerificationMark
|
||||||
|
{
|
||||||
|
public VerificationMarkType Type { get; set; }
|
||||||
|
[MaxLength(1024)] public string? Title { get; set; }
|
||||||
|
[MaxLength(8192)] public string? Description { get; set; }
|
||||||
|
[MaxLength(1024)] public string? VerifiedBy { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum VerificationMarkType
|
||||||
|
{
|
||||||
|
Official,
|
||||||
|
Individual,
|
||||||
|
Organization,
|
||||||
|
Government,
|
||||||
|
Creator
|
||||||
|
}
|
275
DysonNetwork.Pass/AppDatabase.cs
Normal file
275
DysonNetwork.Pass/AppDatabase.cs
Normal file
@ -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<AppDatabase> options,
|
||||||
|
IConfiguration configuration
|
||||||
|
) : DbContext(options)
|
||||||
|
{
|
||||||
|
public DbSet<PermissionNode> PermissionNodes { get; set; }
|
||||||
|
public DbSet<PermissionGroup> PermissionGroups { get; set; }
|
||||||
|
public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; }
|
||||||
|
|
||||||
|
public DbSet<MagicSpell> MagicSpells { get; set; }
|
||||||
|
public DbSet<Account.Account> Accounts { get; set; }
|
||||||
|
public DbSet<AccountConnection> AccountConnections { get; set; }
|
||||||
|
public DbSet<Profile> AccountProfiles { get; set; }
|
||||||
|
public DbSet<AccountContact> AccountContacts { get; set; }
|
||||||
|
public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; }
|
||||||
|
public DbSet<Relationship> AccountRelationships { get; set; }
|
||||||
|
public DbSet<Status> AccountStatuses { get; set; }
|
||||||
|
public DbSet<CheckInResult> AccountCheckInResults { get; set; }
|
||||||
|
public DbSet<Notification> Notifications { get; set; }
|
||||||
|
public DbSet<NotificationPushSubscription> NotificationPushSubscriptions { get; set; }
|
||||||
|
public DbSet<Badge> Badges { get; set; }
|
||||||
|
public DbSet<ActionLog> ActionLogs { get; set; }
|
||||||
|
public DbSet<AbuseReport> AbuseReports { get; set; }
|
||||||
|
|
||||||
|
public DbSet<Session> AuthSessions { get; set; }
|
||||||
|
public DbSet<Challenge> AuthChallenges { get; set; }
|
||||||
|
|
||||||
|
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<PermissionGroup>()
|
||||||
|
.FirstOrDefaultAsync(g => g.Key == "default", cancellationToken);
|
||||||
|
if (defaultPermissionGroup is null)
|
||||||
|
{
|
||||||
|
context.Set<PermissionGroup>().Add(new PermissionGroup
|
||||||
|
{
|
||||||
|
Key = "default",
|
||||||
|
Nodes = new List<string>
|
||||||
|
{
|
||||||
|
"posts.create",
|
||||||
|
"posts.react",
|
||||||
|
"publishers.create",
|
||||||
|
"files.create",
|
||||||
|
"chat.create",
|
||||||
|
"chat.messages.create",
|
||||||
|
"chat.realtime.create",
|
||||||
|
"accounts.statuses.create",
|
||||||
|
"accounts.statuses.update",
|
||||||
|
"stickers.packs.create",
|
||||||
|
"stickers.create"
|
||||||
|
}.Select(permission =>
|
||||||
|
PermissionService.NewPermissionNode("group:default", "global", permission, true))
|
||||||
|
.ToList()
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
optionsBuilder.UseSeeding((context, _) => {});
|
||||||
|
|
||||||
|
base.OnConfiguring(optionsBuilder);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PermissionGroupMember>()
|
||||||
|
.HasKey(pg => new { pg.GroupId, pg.Actor });
|
||||||
|
modelBuilder.Entity<PermissionGroupMember>()
|
||||||
|
.HasOne(pg => pg.Group)
|
||||||
|
.WithMany(g => g.Members)
|
||||||
|
.HasForeignKey(pg => pg.GroupId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Relationship>()
|
||||||
|
.HasKey(r => new { FromAccountId = r.AccountId, ToAccountId = r.RelatedId });
|
||||||
|
modelBuilder.Entity<Relationship>()
|
||||||
|
.HasOne(r => r.Account)
|
||||||
|
.WithMany(a => a.OutgoingRelationships)
|
||||||
|
.HasForeignKey(r => r.AccountId);
|
||||||
|
modelBuilder.Entity<Relationship>()
|
||||||
|
.HasOne(r => r.Related)
|
||||||
|
.WithMany(a => a.IncomingRelationships)
|
||||||
|
.HasForeignKey(r => r.RelatedId);
|
||||||
|
|
||||||
|
// Automatically apply soft-delete filter to all entities inheriting BaseModel
|
||||||
|
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||||
|
{
|
||||||
|
if (!typeof(ModelBase).IsAssignableFrom(entityType.ClrType)) continue;
|
||||||
|
var method = typeof(AppDatabase)
|
||||||
|
.GetMethod(nameof(SetSoftDeleteFilter),
|
||||||
|
BindingFlags.NonPublic | BindingFlags.Static)!
|
||||||
|
.MakeGenericMethod(entityType.ClrType);
|
||||||
|
|
||||||
|
method.Invoke(null, [modelBuilder]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
|
||||||
|
where TEntity : ModelBase
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<TEntity>().HasQueryFilter(e => e.DeletedAt == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
|
||||||
|
foreach (var entry in ChangeTracker.Entries<ModelBase>())
|
||||||
|
{
|
||||||
|
switch (entry.State)
|
||||||
|
{
|
||||||
|
case EntityState.Added:
|
||||||
|
entry.Entity.CreatedAt = now;
|
||||||
|
entry.Entity.UpdatedAt = now;
|
||||||
|
break;
|
||||||
|
case EntityState.Modified:
|
||||||
|
entry.Entity.UpdatedAt = now;
|
||||||
|
break;
|
||||||
|
case EntityState.Deleted:
|
||||||
|
entry.State = EntityState.Modified;
|
||||||
|
entry.Entity.DeletedAt = now;
|
||||||
|
break;
|
||||||
|
case EntityState.Detached:
|
||||||
|
case EntityState.Unchanged:
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await base.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AppDatabaseRecyclingJob(AppDatabase db, ILogger<AppDatabaseRecyclingJob> logger) : IJob
|
||||||
|
{
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
|
||||||
|
logger.LogInformation("Cleaning up expired records...");
|
||||||
|
|
||||||
|
// Expired relationships
|
||||||
|
var affectedRows = await db.AccountRelationships
|
||||||
|
.Where(x => x.ExpiredAt != null && x.ExpiredAt <= now)
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
logger.LogDebug("Removed {Count} records of expired relationships.", affectedRows);
|
||||||
|
// Expired permission group members
|
||||||
|
affectedRows = await db.PermissionGroupMembers
|
||||||
|
.Where(x => x.ExpiredAt != null && x.ExpiredAt <= now)
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
logger.LogDebug("Removed {Count} records of expired permission group members.", affectedRows);
|
||||||
|
|
||||||
|
logger.LogInformation("Deleting soft-deleted records...");
|
||||||
|
|
||||||
|
var threshold = now - Duration.FromDays(7);
|
||||||
|
|
||||||
|
var entityTypes = db.Model.GetEntityTypes()
|
||||||
|
.Where(t => typeof(ModelBase).IsAssignableFrom(t.ClrType) && t.ClrType != typeof(ModelBase))
|
||||||
|
.Select(t => t.ClrType);
|
||||||
|
|
||||||
|
foreach (var entityType in entityTypes)
|
||||||
|
{
|
||||||
|
var set = (IQueryable)db.GetType().GetMethod(nameof(DbContext.Set), Type.EmptyTypes)!
|
||||||
|
.MakeGenericMethod(entityType).Invoke(db, null)!;
|
||||||
|
var parameter = Expression.Parameter(entityType, "e");
|
||||||
|
var property = Expression.Property(parameter, nameof(ModelBase.DeletedAt));
|
||||||
|
var condition = Expression.LessThan(property, Expression.Constant(threshold, typeof(Instant?)));
|
||||||
|
var notNull = Expression.NotEqual(property, Expression.Constant(null, typeof(Instant?)));
|
||||||
|
var finalCondition = Expression.AndAlso(notNull, condition);
|
||||||
|
var lambda = Expression.Lambda(finalCondition, parameter);
|
||||||
|
|
||||||
|
var queryable = set.Provider.CreateQuery(
|
||||||
|
Expression.Call(
|
||||||
|
typeof(Queryable),
|
||||||
|
"Where",
|
||||||
|
[entityType],
|
||||||
|
set.Expression,
|
||||||
|
Expression.Quote(lambda)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
var toListAsync = typeof(EntityFrameworkQueryableExtensions)
|
||||||
|
.GetMethod(nameof(EntityFrameworkQueryableExtensions.ToListAsync))!
|
||||||
|
.MakeGenericMethod(entityType);
|
||||||
|
|
||||||
|
var items = await (dynamic)toListAsync.Invoke(null, [queryable, CancellationToken.None])!;
|
||||||
|
db.RemoveRange(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AppDatabaseFactory : IDesignTimeDbContextFactory<AppDatabase>
|
||||||
|
{
|
||||||
|
public AppDatabase CreateDbContext(string[] args)
|
||||||
|
{
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.SetBasePath(Directory.GetCurrentDirectory())
|
||||||
|
.AddJsonFile("appsettings.json")
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var optionsBuilder = new DbContextOptionsBuilder<AppDatabase>();
|
||||||
|
return new AppDatabase(optionsBuilder.Options, configuration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class OptionalQueryExtensions
|
||||||
|
{
|
||||||
|
public static IQueryable<T> If<T>(
|
||||||
|
this IQueryable<T> source,
|
||||||
|
bool condition,
|
||||||
|
Func<IQueryable<T>, IQueryable<T>> transform
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return condition ? transform(source) : source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<T> If<T, TP>(
|
||||||
|
this IIncludableQueryable<T, TP> source,
|
||||||
|
bool condition,
|
||||||
|
Func<IIncludableQueryable<T, TP>, IQueryable<T>> transform
|
||||||
|
)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
return condition ? transform(source) : source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<T> If<T, TP>(
|
||||||
|
this IIncludableQueryable<T, IEnumerable<TP>> source,
|
||||||
|
bool condition,
|
||||||
|
Func<IIncludableQueryable<T, IEnumerable<TP>>, IQueryable<T>> transform
|
||||||
|
)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
return condition ? transform(source) : source;
|
||||||
|
}
|
||||||
|
}
|
273
DysonNetwork.Pass/Auth/Auth.cs
Normal file
273
DysonNetwork.Pass/Auth/Auth.cs
Normal file
@ -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<DysonTokenAuthOptions> options,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILoggerFactory logger,
|
||||||
|
UrlEncoder encoder,
|
||||||
|
AppDatabase database,
|
||||||
|
OidcProviderService oidc,
|
||||||
|
ICacheService cache,
|
||||||
|
FlushBufferService fbs
|
||||||
|
)
|
||||||
|
: AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder)
|
||||||
|
{
|
||||||
|
public const string AuthCachePrefix = "auth:";
|
||||||
|
|
||||||
|
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
|
{
|
||||||
|
var tokenInfo = _ExtractToken(Request);
|
||||||
|
|
||||||
|
if (tokenInfo == null || string.IsNullOrEmpty(tokenInfo.Token))
|
||||||
|
return AuthenticateResult.Fail("No token was provided.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
|
||||||
|
// Validate token and extract session ID
|
||||||
|
if (!ValidateToken(tokenInfo.Token, out var sessionId))
|
||||||
|
return AuthenticateResult.Fail("Invalid token.");
|
||||||
|
|
||||||
|
// Try to get session from cache first
|
||||||
|
var session = await cache.GetAsync<Session>($"{AuthCachePrefix}{sessionId}");
|
||||||
|
|
||||||
|
// If not in cache, load from database
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
session = await database.AuthSessions
|
||||||
|
.Where(e => e.Id == sessionId)
|
||||||
|
.Include(e => e.Challenge)
|
||||||
|
.Include(e => e.Account)
|
||||||
|
.ThenInclude(e => e.Profile)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (session is not null)
|
||||||
|
{
|
||||||
|
// Store in cache for future requests
|
||||||
|
await cache.SetWithGroupsAsync(
|
||||||
|
$"auth:{sessionId}",
|
||||||
|
session,
|
||||||
|
[$"{AccountService.AccountCachePrefix}{session.Account.Id}"],
|
||||||
|
TimeSpan.FromHours(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the session exists
|
||||||
|
if (session == null)
|
||||||
|
return AuthenticateResult.Fail("Session not found.");
|
||||||
|
|
||||||
|
// Check if the session is expired
|
||||||
|
if (session.ExpiredAt.HasValue && session.ExpiredAt.Value < now)
|
||||||
|
return AuthenticateResult.Fail("Session expired.");
|
||||||
|
|
||||||
|
// Store user and session in the HttpContext.Items for easy access in controllers
|
||||||
|
Context.Items["CurrentUser"] = session.Account;
|
||||||
|
Context.Items["CurrentSession"] = session;
|
||||||
|
Context.Items["CurrentTokenType"] = tokenInfo.Type.ToString();
|
||||||
|
|
||||||
|
// Create claims from the session
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new("user_id", session.Account.Id.ToString()),
|
||||||
|
new("session_id", session.Id.ToString()),
|
||||||
|
new("token_type", tokenInfo.Type.ToString())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add scopes as claims
|
||||||
|
session.Challenge.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope)));
|
||||||
|
|
||||||
|
// Add superuser claim if applicable
|
||||||
|
if (session.Account.IsSuperuser)
|
||||||
|
claims.Add(new Claim("is_superuser", "1"));
|
||||||
|
|
||||||
|
// Create the identity and principal
|
||||||
|
var identity = new ClaimsIdentity(claims, AuthConstants.SchemeName);
|
||||||
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
|
||||||
|
var ticket = new AuthenticationTicket(principal, AuthConstants.SchemeName);
|
||||||
|
|
||||||
|
var lastInfo = new LastActiveInfo
|
||||||
|
{
|
||||||
|
Account = session.Account,
|
||||||
|
Session = session,
|
||||||
|
SeenAt = SystemClock.Instance.GetCurrentInstant(),
|
||||||
|
};
|
||||||
|
fbs.Enqueue(lastInfo);
|
||||||
|
|
||||||
|
return AuthenticateResult.Success(ticket);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail($"Authentication failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ValidateToken(string token, out Guid sessionId)
|
||||||
|
{
|
||||||
|
sessionId = Guid.Empty;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parts = token.Split('.');
|
||||||
|
|
||||||
|
switch (parts.Length)
|
||||||
|
{
|
||||||
|
// Handle JWT tokens (3 parts)
|
||||||
|
case 3:
|
||||||
|
{
|
||||||
|
var (isValid, jwtResult) = oidc.ValidateToken(token);
|
||||||
|
if (!isValid) return false;
|
||||||
|
var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value;
|
||||||
|
if (jti is null) return false;
|
||||||
|
|
||||||
|
return Guid.TryParse(jti, out sessionId);
|
||||||
|
}
|
||||||
|
// Handle compact tokens (2 parts)
|
||||||
|
case 2:
|
||||||
|
// Original compact token validation logic
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Decode the payload
|
||||||
|
var payloadBytes = Base64UrlDecode(parts[0]);
|
||||||
|
|
||||||
|
// Extract session ID
|
||||||
|
sessionId = new Guid(payloadBytes);
|
||||||
|
|
||||||
|
// Load public key for verification
|
||||||
|
var publicKeyPem = File.ReadAllText(configuration["AuthToken:PublicKeyPath"]!);
|
||||||
|
using var rsa = RSA.Create();
|
||||||
|
rsa.ImportFromPem(publicKeyPem);
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
var signature = Base64UrlDecode(parts[1]);
|
||||||
|
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogWarning(ex, "Token validation failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Base64UrlDecode(string base64Url)
|
||||||
|
{
|
||||||
|
var padded = base64Url
|
||||||
|
.Replace('-', '+')
|
||||||
|
.Replace('_', '/');
|
||||||
|
|
||||||
|
switch (padded.Length % 4)
|
||||||
|
{
|
||||||
|
case 2: padded += "=="; break;
|
||||||
|
case 3: padded += "="; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Convert.FromBase64String(padded);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TokenInfo? _ExtractToken(HttpRequest request)
|
||||||
|
{
|
||||||
|
// Check for token in query parameters
|
||||||
|
if (request.Query.TryGetValue(AuthConstants.TokenQueryParamName, out var queryToken))
|
||||||
|
{
|
||||||
|
return new TokenInfo
|
||||||
|
{
|
||||||
|
Token = queryToken.ToString(),
|
||||||
|
Type = TokenType.AuthKey
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Check for token in Authorization header
|
||||||
|
var authHeader = request.Headers.Authorization.ToString();
|
||||||
|
if (!string.IsNullOrEmpty(authHeader))
|
||||||
|
{
|
||||||
|
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var token = authHeader["Bearer ".Length..].Trim();
|
||||||
|
var parts = token.Split('.');
|
||||||
|
|
||||||
|
return new TokenInfo
|
||||||
|
{
|
||||||
|
Token = token,
|
||||||
|
Type = parts.Length == 3 ? TokenType.OidcKey : TokenType.AuthKey
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return new TokenInfo
|
||||||
|
{
|
||||||
|
Token = authHeader["AtField ".Length..].Trim(),
|
||||||
|
Type = TokenType.AuthKey
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return new TokenInfo
|
||||||
|
{
|
||||||
|
Token = authHeader["AkField ".Length..].Trim(),
|
||||||
|
Type = TokenType.ApiKey
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for token in cookies
|
||||||
|
if (request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out var cookieToken))
|
||||||
|
{
|
||||||
|
return new TokenInfo
|
||||||
|
{
|
||||||
|
Token = cookieToken,
|
||||||
|
Type = cookieToken.Count(c => c == '.') == 2 ? TokenType.OidcKey : TokenType.AuthKey
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
266
DysonNetwork.Pass/Auth/AuthController.cs
Normal file
266
DysonNetwork.Pass/Auth/AuthController.cs
Normal file
@ -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<string> Audiences { get; set; } = new();
|
||||||
|
public List<string> Scopes { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("challenge")]
|
||||||
|
public async Task<ActionResult<Challenge>> StartChallenge([FromBody] ChallengeRequest request)
|
||||||
|
{
|
||||||
|
var account = await accounts.LookupAccount(request.Account);
|
||||||
|
if (account is null) return NotFound("Account was not found.");
|
||||||
|
|
||||||
|
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
|
||||||
|
|
||||||
|
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
||||||
|
|
||||||
|
// Trying to pick up challenges from the same IP address and user agent
|
||||||
|
var existingChallenge = await db.AuthChallenges
|
||||||
|
.Where(e => e.Account == account)
|
||||||
|
.Where(e => e.IpAddress == ipAddress)
|
||||||
|
.Where(e => e.UserAgent == userAgent)
|
||||||
|
.Where(e => e.StepRemain > 0)
|
||||||
|
.Where(e => e.ExpiredAt != null && now < e.ExpiredAt)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (existingChallenge is not null) return existingChallenge;
|
||||||
|
|
||||||
|
var challenge = new Challenge
|
||||||
|
{
|
||||||
|
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)),
|
||||||
|
StepTotal = await auth.DetectChallengeRisk(Request, account),
|
||||||
|
Platform = request.Platform,
|
||||||
|
Audiences = request.Audiences,
|
||||||
|
Scopes = request.Scopes,
|
||||||
|
IpAddress = ipAddress,
|
||||||
|
UserAgent = userAgent,
|
||||||
|
Location = geo.GetPointFromIp(ipAddress),
|
||||||
|
DeviceId = request.DeviceId,
|
||||||
|
AccountId = account.Id
|
||||||
|
}.Normalize();
|
||||||
|
|
||||||
|
await db.AuthChallenges.AddAsync(challenge);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
als.CreateActionLogFromRequest(ActionLogType.ChallengeAttempt,
|
||||||
|
new Dictionary<string, object> { { "challenge_id", challenge.Id } }, Request, account
|
||||||
|
);
|
||||||
|
|
||||||
|
return challenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("challenge/{id:guid}")]
|
||||||
|
public async Task<ActionResult<Challenge>> GetChallenge([FromRoute] Guid id)
|
||||||
|
{
|
||||||
|
var challenge = await db.AuthChallenges
|
||||||
|
.Include(e => e.Account)
|
||||||
|
.ThenInclude(e => e.Profile)
|
||||||
|
.FirstOrDefaultAsync(e => e.Id == id);
|
||||||
|
|
||||||
|
return challenge is null
|
||||||
|
? NotFound("Auth challenge was not found.")
|
||||||
|
: challenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("challenge/{id:guid}/factors")]
|
||||||
|
public async Task<ActionResult<List<AccountAuthFactor>>> GetChallengeFactors([FromRoute] Guid id)
|
||||||
|
{
|
||||||
|
var challenge = await db.AuthChallenges
|
||||||
|
.Include(e => e.Account)
|
||||||
|
.Include(e => e.Account.AuthFactors)
|
||||||
|
.Where(e => e.Id == id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
return challenge is null
|
||||||
|
? NotFound("Auth challenge was not found.")
|
||||||
|
: challenge.Account.AuthFactors.Where(e => e is { EnabledAt: not null, Trustworthy: >= 1 }).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("challenge/{id:guid}/factors/{factorId:guid}")]
|
||||||
|
public async Task<ActionResult> RequestFactorCode(
|
||||||
|
[FromRoute] Guid id,
|
||||||
|
[FromRoute] Guid factorId,
|
||||||
|
[FromBody] string? hint
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var challenge = await db.AuthChallenges
|
||||||
|
.Include(e => e.Account)
|
||||||
|
.Where(e => e.Id == id).FirstOrDefaultAsync();
|
||||||
|
if (challenge is null) return NotFound("Auth challenge was not found.");
|
||||||
|
var factor = await db.AccountAuthFactors
|
||||||
|
.Where(e => e.Id == factorId)
|
||||||
|
.Where(e => e.Account == challenge.Account).FirstOrDefaultAsync();
|
||||||
|
if (factor is null) return NotFound("Auth factor was not found.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await accounts.SendFactorCode(challenge.Account, factor, hint);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PerformChallengeRequest
|
||||||
|
{
|
||||||
|
[Required] public Guid FactorId { get; set; }
|
||||||
|
[Required] public string Password { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("challenge/{id:guid}")]
|
||||||
|
public async Task<ActionResult<Challenge>> DoChallenge(
|
||||||
|
[FromRoute] Guid id,
|
||||||
|
[FromBody] PerformChallengeRequest request
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var challenge = await db.AuthChallenges.Include(e => e.Account).FirstOrDefaultAsync(e => e.Id == id);
|
||||||
|
if (challenge is null) return NotFound("Auth challenge was not found.");
|
||||||
|
|
||||||
|
var factor = await db.AccountAuthFactors.FindAsync(request.FactorId);
|
||||||
|
if (factor is null) return NotFound("Auth factor was not found.");
|
||||||
|
if (factor.EnabledAt is null) return BadRequest("Auth factor is not enabled.");
|
||||||
|
if (factor.Trustworthy <= 0) return BadRequest("Auth factor is not trustworthy.");
|
||||||
|
|
||||||
|
if (challenge.StepRemain == 0) return challenge;
|
||||||
|
if (challenge.ExpiredAt.HasValue && challenge.ExpiredAt.Value < Instant.FromDateTimeUtc(DateTime.UtcNow))
|
||||||
|
return BadRequest();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (await accounts.VerifyFactorCode(factor, request.Password))
|
||||||
|
{
|
||||||
|
challenge.StepRemain -= factor.Trustworthy;
|
||||||
|
challenge.StepRemain = Math.Max(0, challenge.StepRemain);
|
||||||
|
challenge.BlacklistFactors.Add(factor.Id);
|
||||||
|
db.Update(challenge);
|
||||||
|
als.CreateActionLogFromRequest(ActionLogType.ChallengeSuccess,
|
||||||
|
new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "challenge_id", challenge.Id },
|
||||||
|
{ "factor_id", factor.Id }
|
||||||
|
}, Request, challenge.Account
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Invalid password.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
challenge.FailedAttempts++;
|
||||||
|
db.Update(challenge);
|
||||||
|
als.CreateActionLogFromRequest(ActionLogType.ChallengeFailure,
|
||||||
|
new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "challenge_id", challenge.Id },
|
||||||
|
{ "factor_id", factor.Id }
|
||||||
|
}, Request, challenge.Account
|
||||||
|
);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return BadRequest("Invalid password.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (challenge.StepRemain == 0)
|
||||||
|
{
|
||||||
|
als.CreateActionLogFromRequest(ActionLogType.NewLogin,
|
||||||
|
new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "challenge_id", challenge.Id },
|
||||||
|
{ "account_id", challenge.AccountId }
|
||||||
|
}, Request, challenge.Account
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return challenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TokenExchangeRequest
|
||||||
|
{
|
||||||
|
public string GrantType { get; set; } = string.Empty;
|
||||||
|
public string? RefreshToken { get; set; }
|
||||||
|
public string? Code { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TokenExchangeResponse
|
||||||
|
{
|
||||||
|
public string Token { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("token")]
|
||||||
|
public async Task<ActionResult<TokenExchangeResponse>> ExchangeToken([FromBody] TokenExchangeRequest request)
|
||||||
|
{
|
||||||
|
switch (request.GrantType)
|
||||||
|
{
|
||||||
|
case "authorization_code":
|
||||||
|
var code = Guid.TryParse(request.Code, out var codeId) ? codeId : Guid.Empty;
|
||||||
|
if (code == Guid.Empty)
|
||||||
|
return BadRequest("Invalid or missing authorization code.");
|
||||||
|
var challenge = await db.AuthChallenges
|
||||||
|
.Include(e => e.Account)
|
||||||
|
.Where(e => e.Id == code)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (challenge is null)
|
||||||
|
return BadRequest("Authorization code not found or expired.");
|
||||||
|
if (challenge.StepRemain != 0)
|
||||||
|
return BadRequest("Challenge not yet completed.");
|
||||||
|
|
||||||
|
var session = await db.AuthSessions
|
||||||
|
.Where(e => e.Challenge == challenge)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (session is not null)
|
||||||
|
return BadRequest("Session already exists for this challenge.");
|
||||||
|
|
||||||
|
session = new Session
|
||||||
|
{
|
||||||
|
LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow),
|
||||||
|
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)),
|
||||||
|
Account = challenge.Account,
|
||||||
|
Challenge = challenge,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.AuthSessions.Add(session);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var tk = auth.CreateToken(session);
|
||||||
|
return Ok(new TokenExchangeResponse { Token = tk });
|
||||||
|
case "refresh_token":
|
||||||
|
// Since we no longer need the refresh token
|
||||||
|
// This case is blank for now, thinking to mock it if the OIDC standard requires it
|
||||||
|
default:
|
||||||
|
return BadRequest("Unsupported grant type.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("captcha")]
|
||||||
|
public async Task<ActionResult> ValidateCaptcha([FromBody] string token)
|
||||||
|
{
|
||||||
|
var result = await auth.ValidateCaptcha(token);
|
||||||
|
return result ? Ok() : BadRequest();
|
||||||
|
}
|
||||||
|
}
|
304
DysonNetwork.Pass/Auth/AuthService.cs
Normal file
304
DysonNetwork.Pass/Auth/AuthService.cs
Normal file
@ -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!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detect the risk of the current request to login
|
||||||
|
/// and returns the required steps to login.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request context</param>
|
||||||
|
/// <param name="account">The account to login</param>
|
||||||
|
/// <returns>The required steps to login</returns>
|
||||||
|
public async Task<int> DetectChallengeRisk(HttpRequest request, Account.Account account)
|
||||||
|
{
|
||||||
|
// 1) Find out how many authentication factors the account has enabled.
|
||||||
|
var maxSteps = await db.AccountAuthFactors
|
||||||
|
.Where(f => f.AccountId == account.Id)
|
||||||
|
.Where(f => f.EnabledAt != null)
|
||||||
|
.CountAsync();
|
||||||
|
|
||||||
|
// We’ll accumulate a “risk score” based on various factors.
|
||||||
|
// Then we can decide how many total steps are required for the challenge.
|
||||||
|
var riskScore = 0;
|
||||||
|
|
||||||
|
// 2) Get the remote IP address from the request (if any).
|
||||||
|
var ipAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
var lastActiveInfo = await db.AuthSessions
|
||||||
|
.OrderByDescending(s => s.LastGrantedAt)
|
||||||
|
.Include(s => s.Challenge)
|
||||||
|
.Where(s => s.AccountId == account.Id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
// Example check: if IP is missing or in an unusual range, increase the risk.
|
||||||
|
// (This is just a placeholder; in reality, you’d integrate with GeoIpService or a custom check.)
|
||||||
|
if (string.IsNullOrWhiteSpace(ipAddress))
|
||||||
|
riskScore += 1;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge.IpAddress) &&
|
||||||
|
!lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase))
|
||||||
|
riskScore += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) (Optional) Check how recent the last login was.
|
||||||
|
// If it was a long time ago, the risk might be higher.
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var daysSinceLastActive = lastActiveInfo?.LastGrantedAt is not null
|
||||||
|
? (now - lastActiveInfo.LastGrantedAt.Value).TotalDays
|
||||||
|
: double.MaxValue;
|
||||||
|
if (daysSinceLastActive > 30)
|
||||||
|
riskScore += 1;
|
||||||
|
|
||||||
|
// 4) Combine base “maxSteps” (the number of enabled factors) with any accumulated risk score.
|
||||||
|
const int totalRiskScore = 3;
|
||||||
|
var totalRequiredSteps = (int)Math.Round((float)maxSteps * riskScore / totalRiskScore);
|
||||||
|
// Clamp the steps
|
||||||
|
totalRequiredSteps = Math.Max(Math.Min(totalRequiredSteps, maxSteps), 1);
|
||||||
|
|
||||||
|
return totalRequiredSteps;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Session> CreateSessionForOidcAsync(Account.Account account, Instant time, Guid? customAppId = null)
|
||||||
|
{
|
||||||
|
var challenge = new Challenge
|
||||||
|
{
|
||||||
|
AccountId = account.Id,
|
||||||
|
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
UserAgent = HttpContext.Request.Headers.UserAgent,
|
||||||
|
StepRemain = 1,
|
||||||
|
StepTotal = 1,
|
||||||
|
Type = customAppId is not null ? ChallengeType.OAuth : ChallengeType.Oidc
|
||||||
|
};
|
||||||
|
|
||||||
|
var session = new Session
|
||||||
|
{
|
||||||
|
AccountId = account.Id,
|
||||||
|
CreatedAt = time,
|
||||||
|
LastGrantedAt = time,
|
||||||
|
Challenge = challenge,
|
||||||
|
AppId = customAppId
|
||||||
|
};
|
||||||
|
|
||||||
|
db.AuthChallenges.Add(challenge);
|
||||||
|
db.AuthSessions.Add(session);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ValidateCaptcha(string token)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(token)) return false;
|
||||||
|
|
||||||
|
var provider = config.GetSection("Captcha")["Provider"]?.ToLower();
|
||||||
|
var apiSecret = config.GetSection("Captcha")["ApiSecret"];
|
||||||
|
|
||||||
|
var client = httpClientFactory.CreateClient();
|
||||||
|
|
||||||
|
var jsonOpts = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||||
|
DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (provider)
|
||||||
|
{
|
||||||
|
case "cloudflare":
|
||||||
|
var content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
|
||||||
|
"application/x-www-form-urlencoded");
|
||||||
|
var response = await client.PostAsync("https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||||
|
content);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts);
|
||||||
|
|
||||||
|
return result?.Success == true;
|
||||||
|
case "google":
|
||||||
|
content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
|
||||||
|
"application/x-www-form-urlencoded");
|
||||||
|
response = await client.PostAsync("https://www.google.com/recaptcha/siteverify", content);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
json = await response.Content.ReadAsStringAsync();
|
||||||
|
result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts);
|
||||||
|
|
||||||
|
return result?.Success == true;
|
||||||
|
case "hcaptcha":
|
||||||
|
content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
|
||||||
|
"application/x-www-form-urlencoded");
|
||||||
|
response = await client.PostAsync("https://hcaptcha.com/siteverify", content);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
json = await response.Content.ReadAsStringAsync();
|
||||||
|
result = JsonSerializer.Deserialize<CaptchaVerificationResponse>(json, options: jsonOpts);
|
||||||
|
|
||||||
|
return result?.Success == true;
|
||||||
|
default:
|
||||||
|
throw new ArgumentException("The server misconfigured for the captcha.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CreateToken(Session session)
|
||||||
|
{
|
||||||
|
// Load the private key for signing
|
||||||
|
var privateKeyPem = File.ReadAllText(config["AuthToken:PrivateKeyPath"]!);
|
||||||
|
using var rsa = RSA.Create();
|
||||||
|
rsa.ImportFromPem(privateKeyPem);
|
||||||
|
|
||||||
|
// Create and return a single token
|
||||||
|
return CreateCompactToken(session.Id, rsa);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string CreateCompactToken(Guid sessionId, RSA rsa)
|
||||||
|
{
|
||||||
|
// Create the payload: just the session ID
|
||||||
|
var payloadBytes = sessionId.ToByteArray();
|
||||||
|
|
||||||
|
// Base64Url encode the payload
|
||||||
|
var payloadBase64 = Base64UrlEncode(payloadBytes);
|
||||||
|
|
||||||
|
// Sign the payload with RSA-SHA256
|
||||||
|
var signature = rsa.SignData(payloadBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||||
|
|
||||||
|
// Base64Url encode the signature
|
||||||
|
var signatureBase64 = Base64UrlEncode(signature);
|
||||||
|
|
||||||
|
// Combine payload and signature with a dot
|
||||||
|
return $"{payloadBase64}.{signatureBase64}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ValidateSudoMode(Session session, string? pinCode)
|
||||||
|
{
|
||||||
|
// Check if the session is already in sudo mode (cached)
|
||||||
|
var sudoModeKey = $"accounts:{session.Id}:sudo";
|
||||||
|
var (found, _) = await cache.GetAsyncWithStatus<bool>(sudoModeKey);
|
||||||
|
|
||||||
|
if (found)
|
||||||
|
{
|
||||||
|
// Session is already in sudo mode
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user has a pin code
|
||||||
|
var hasPinCode = await db.AccountAuthFactors
|
||||||
|
.Where(f => f.AccountId == session.AccountId)
|
||||||
|
.Where(f => f.EnabledAt != null)
|
||||||
|
.Where(f => f.Type == AccountAuthFactorType.PinCode)
|
||||||
|
.AnyAsync();
|
||||||
|
|
||||||
|
if (!hasPinCode)
|
||||||
|
{
|
||||||
|
// User doesn't have a pin code, no validation needed
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If pin code is not provided, we can't validate
|
||||||
|
if (string.IsNullOrEmpty(pinCode))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Validate the pin code
|
||||||
|
var isValid = await ValidatePinCode(session.AccountId, pinCode);
|
||||||
|
|
||||||
|
if (isValid)
|
||||||
|
{
|
||||||
|
// Set session in sudo mode for 5 minutes
|
||||||
|
await cache.SetAsync(sudoModeKey, true, TimeSpan.FromMinutes(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
// No pin code enabled for this account, so validation is successful
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ValidatePinCode(Guid accountId, string pinCode)
|
||||||
|
{
|
||||||
|
var factor = await db.AccountAuthFactors
|
||||||
|
.Where(f => f.AccountId == accountId)
|
||||||
|
.Where(f => f.EnabledAt != null)
|
||||||
|
.Where(f => f.Type == AccountAuthFactorType.PinCode)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (factor is null) throw new InvalidOperationException("No pin code enabled for this account.");
|
||||||
|
|
||||||
|
return factor.VerifyPassword(pinCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ValidateToken(string token, out Guid sessionId)
|
||||||
|
{
|
||||||
|
sessionId = Guid.Empty;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Split the token
|
||||||
|
var parts = token.Split('.');
|
||||||
|
if (parts.Length != 2)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Decode the payload
|
||||||
|
var payloadBytes = Base64UrlDecode(parts[0]);
|
||||||
|
|
||||||
|
// Extract session ID
|
||||||
|
sessionId = new Guid(payloadBytes);
|
||||||
|
|
||||||
|
// Load public key for verification
|
||||||
|
var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!);
|
||||||
|
using var rsa = RSA.Create();
|
||||||
|
rsa.ImportFromPem(publicKeyPem);
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
var signature = Base64UrlDecode(parts[1]);
|
||||||
|
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods for Base64Url encoding/decoding
|
||||||
|
private static string Base64UrlEncode(byte[] data)
|
||||||
|
{
|
||||||
|
return Convert.ToBase64String(data)
|
||||||
|
.TrimEnd('=')
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] Base64UrlDecode(string base64Url)
|
||||||
|
{
|
||||||
|
string padded = base64Url
|
||||||
|
.Replace('-', '+')
|
||||||
|
.Replace('_', '/');
|
||||||
|
|
||||||
|
switch (padded.Length % 4)
|
||||||
|
{
|
||||||
|
case 2: padded += "=="; break;
|
||||||
|
case 3: padded += "="; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Convert.FromBase64String(padded);
|
||||||
|
}
|
||||||
|
}
|
6
DysonNetwork.Pass/Auth/CheckpointModel.cs
Normal file
6
DysonNetwork.Pass/Auth/CheckpointModel.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace DysonNetwork.Pass.Auth;
|
||||||
|
|
||||||
|
public class CaptchaVerificationResponse
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
}
|
94
DysonNetwork.Pass/Auth/CompactTokenService.cs
Normal file
94
DysonNetwork.Pass/Auth/CompactTokenService.cs
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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<OidcProviderOptions> options,
|
||||||
|
ILogger<OidcProviderController> logger
|
||||||
|
)
|
||||||
|
: ControllerBase
|
||||||
|
{
|
||||||
|
[HttpPost("token")]
|
||||||
|
[Consumes("application/x-www-form-urlencoded")]
|
||||||
|
public async Task<IActionResult> Token([FromForm] TokenRequest request)
|
||||||
|
{
|
||||||
|
switch (request.GrantType)
|
||||||
|
{
|
||||||
|
// Validate client credentials
|
||||||
|
case "authorization_code" when request.ClientId == null || string.IsNullOrEmpty(request.ClientSecret):
|
||||||
|
return BadRequest("Client credentials are required");
|
||||||
|
case "authorization_code" when request.Code == null:
|
||||||
|
return BadRequest("Authorization code is required");
|
||||||
|
case "authorization_code":
|
||||||
|
{
|
||||||
|
var client = await oidcService.FindClientByIdAsync(request.ClientId.Value);
|
||||||
|
if (client == null ||
|
||||||
|
!await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret))
|
||||||
|
return BadRequest(new ErrorResponse
|
||||||
|
{ Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
||||||
|
clientId: request.ClientId.Value,
|
||||||
|
authorizationCode: request.Code!,
|
||||||
|
redirectUri: request.RedirectUri,
|
||||||
|
codeVerifier: request.CodeVerifier
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(tokenResponse);
|
||||||
|
}
|
||||||
|
case "refresh_token" when string.IsNullOrEmpty(request.RefreshToken):
|
||||||
|
return BadRequest(new ErrorResponse
|
||||||
|
{ Error = "invalid_request", ErrorDescription = "Refresh token is required" });
|
||||||
|
case "refresh_token":
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Decode the base64 refresh token to get the session ID
|
||||||
|
var sessionIdBytes = Convert.FromBase64String(request.RefreshToken);
|
||||||
|
var sessionId = new Guid(sessionIdBytes);
|
||||||
|
|
||||||
|
// Find the session and related data
|
||||||
|
var session = await oidcService.FindSessionByIdAsync(sessionId);
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
if (session?.App is null || session.ExpiredAt < now)
|
||||||
|
{
|
||||||
|
return BadRequest(new ErrorResponse
|
||||||
|
{
|
||||||
|
Error = "invalid_grant",
|
||||||
|
ErrorDescription = "Invalid or expired refresh token"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the client
|
||||||
|
var client = session.App;
|
||||||
|
if (client == null)
|
||||||
|
{
|
||||||
|
return BadRequest(new ErrorResponse
|
||||||
|
{
|
||||||
|
Error = "invalid_client",
|
||||||
|
ErrorDescription = "Client not found"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new tokens
|
||||||
|
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
||||||
|
clientId: session.AppId!.Value,
|
||||||
|
sessionId: session.Id
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(tokenResponse);
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return BadRequest(new ErrorResponse
|
||||||
|
{
|
||||||
|
Error = "invalid_grant",
|
||||||
|
ErrorDescription = "Invalid refresh token format"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("userinfo")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> GetUserInfo()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser ||
|
||||||
|
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
|
||||||
|
|
||||||
|
// Get requested scopes from the token
|
||||||
|
var scopes = currentSession.Challenge.Scopes;
|
||||||
|
|
||||||
|
var userInfo = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["sub"] = currentUser.Id
|
||||||
|
};
|
||||||
|
|
||||||
|
// Include standard claims based on scopes
|
||||||
|
if (scopes.Contains("profile") || scopes.Contains("name"))
|
||||||
|
{
|
||||||
|
userInfo["name"] = currentUser.Name;
|
||||||
|
userInfo["preferred_username"] = currentUser.Nick;
|
||||||
|
}
|
||||||
|
|
||||||
|
var userEmail = await db.AccountContacts
|
||||||
|
.Where(c => c.Type == AccountContactType.Email && c.AccountId == currentUser.Id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (scopes.Contains("email") && userEmail is not null)
|
||||||
|
{
|
||||||
|
userInfo["email"] = userEmail.Content;
|
||||||
|
userInfo["email_verified"] = userEmail.VerifiedAt is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(userInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("/.well-known/openid-configuration")]
|
||||||
|
public IActionResult GetConfiguration()
|
||||||
|
{
|
||||||
|
var baseUrl = configuration["BaseUrl"];
|
||||||
|
var issuer = options.Value.IssuerUri.TrimEnd('/');
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
issuer = issuer,
|
||||||
|
authorization_endpoint = $"{baseUrl}/auth/authorize",
|
||||||
|
token_endpoint = $"{baseUrl}/auth/open/token",
|
||||||
|
userinfo_endpoint = $"{baseUrl}/auth/open/userinfo",
|
||||||
|
jwks_uri = $"{baseUrl}/.well-known/jwks",
|
||||||
|
scopes_supported = new[] { "openid", "profile", "email" },
|
||||||
|
response_types_supported = new[]
|
||||||
|
{ "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token" },
|
||||||
|
grant_types_supported = new[] { "authorization_code", "refresh_token" },
|
||||||
|
token_endpoint_auth_methods_supported = new[] { "client_secret_basic", "client_secret_post" },
|
||||||
|
id_token_signing_alg_values_supported = new[] { "HS256" },
|
||||||
|
subject_types_supported = new[] { "public" },
|
||||||
|
claims_supported = new[] { "sub", "name", "email", "email_verified" },
|
||||||
|
code_challenge_methods_supported = new[] { "S256" },
|
||||||
|
response_modes_supported = new[] { "query", "fragment", "form_post" },
|
||||||
|
request_parameter_supported = true,
|
||||||
|
request_uri_parameter_supported = true,
|
||||||
|
require_request_uri_registration = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("/.well-known/jwks")]
|
||||||
|
public IActionResult GetJwks()
|
||||||
|
{
|
||||||
|
using var rsa = options.Value.GetRsaPublicKey();
|
||||||
|
if (rsa == null)
|
||||||
|
{
|
||||||
|
return BadRequest("Public key is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
var parameters = rsa.ExportParameters(false);
|
||||||
|
var keyId = Convert.ToBase64String(SHA256.HashData(parameters.Modulus!)[..8])
|
||||||
|
.Replace("+", "-")
|
||||||
|
.Replace("/", "_")
|
||||||
|
.Replace("=", "");
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
keys = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
kty = "RSA",
|
||||||
|
use = "sig",
|
||||||
|
kid = keyId,
|
||||||
|
n = Base64UrlEncoder.Encode(parameters.Modulus!),
|
||||||
|
e = Base64UrlEncoder.Encode(parameters.Exponent!),
|
||||||
|
alg = "RS256"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TokenRequest
|
||||||
|
{
|
||||||
|
[JsonPropertyName("grant_type")]
|
||||||
|
[FromForm(Name = "grant_type")]
|
||||||
|
public string? GrantType { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("code")]
|
||||||
|
[FromForm(Name = "code")]
|
||||||
|
public string? Code { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("redirect_uri")]
|
||||||
|
[FromForm(Name = "redirect_uri")]
|
||||||
|
public string? RedirectUri { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("client_id")]
|
||||||
|
[FromForm(Name = "client_id")]
|
||||||
|
public Guid? ClientId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("client_secret")]
|
||||||
|
[FromForm(Name = "client_secret")]
|
||||||
|
public string? ClientSecret { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("refresh_token")]
|
||||||
|
[FromForm(Name = "refresh_token")]
|
||||||
|
public string? RefreshToken { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("scope")]
|
||||||
|
[FromForm(Name = "scope")]
|
||||||
|
public string? Scope { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("code_verifier")]
|
||||||
|
[FromForm(Name = "code_verifier")]
|
||||||
|
public string? CodeVerifier { get; set; }
|
||||||
|
}
|
@ -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<string> Scopes { get; set; } = new();
|
||||||
|
public string? CodeChallenge { get; set; }
|
||||||
|
public string? CodeChallengeMethod { get; set; }
|
||||||
|
public string? Nonce { get; set; }
|
||||||
|
public Instant CreatedAt { get; set; }
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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; }
|
||||||
|
}
|
@ -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; }
|
||||||
|
}
|
@ -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; }
|
||||||
|
}
|
@ -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<OidcProviderOptions> options,
|
||||||
|
ILogger<OidcProviderService> logger
|
||||||
|
)
|
||||||
|
{
|
||||||
|
private readonly OidcProviderOptions _options = options.Value;
|
||||||
|
|
||||||
|
public async Task<CustomApp?> FindClientByIdAsync(Guid clientId)
|
||||||
|
{
|
||||||
|
return await db.CustomApps
|
||||||
|
.Include(c => c.Secrets)
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CustomApp?> FindClientByAppIdAsync(Guid appId)
|
||||||
|
{
|
||||||
|
return await db.CustomApps
|
||||||
|
.Include(c => c.Secrets)
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == appId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Session?> FindValidSessionAsync(Guid accountId, Guid clientId)
|
||||||
|
{
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
|
||||||
|
return await db.AuthSessions
|
||||||
|
.Include(s => s.Challenge)
|
||||||
|
.Where(s => s.AccountId == accountId &&
|
||||||
|
s.AppId == clientId &&
|
||||||
|
(s.ExpiredAt == null || s.ExpiredAt > now) &&
|
||||||
|
s.Challenge.Type == ChallengeType.OAuth)
|
||||||
|
.OrderByDescending(s => s.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ValidateClientCredentialsAsync(Guid clientId, string clientSecret)
|
||||||
|
{
|
||||||
|
var client = await FindClientByIdAsync(clientId);
|
||||||
|
if (client == null) return false;
|
||||||
|
|
||||||
|
var clock = SystemClock.Instance;
|
||||||
|
var secret = client.Secrets
|
||||||
|
.Where(s => s.IsOidc && (s.ExpiredAt == null || s.ExpiredAt > clock.GetCurrentInstant()))
|
||||||
|
.FirstOrDefault(s => s.Secret == clientSecret); // In production, use proper hashing
|
||||||
|
|
||||||
|
return secret != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TokenResponse> GenerateTokenResponseAsync(
|
||||||
|
Guid clientId,
|
||||||
|
string? authorizationCode = null,
|
||||||
|
string? redirectUri = null,
|
||||||
|
string? codeVerifier = null,
|
||||||
|
Guid? sessionId = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var client = await FindClientByIdAsync(clientId);
|
||||||
|
if (client == null)
|
||||||
|
throw new InvalidOperationException("Client not found");
|
||||||
|
|
||||||
|
Session session;
|
||||||
|
var clock = SystemClock.Instance;
|
||||||
|
var now = clock.GetCurrentInstant();
|
||||||
|
|
||||||
|
List<string>? scopes = null;
|
||||||
|
if (authorizationCode != null)
|
||||||
|
{
|
||||||
|
// Authorization code flow
|
||||||
|
var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
|
||||||
|
if (authCode is null) throw new InvalidOperationException("Invalid authorization code");
|
||||||
|
var account = await db.Accounts.Where(a => a.Id == authCode.AccountId).FirstOrDefaultAsync();
|
||||||
|
if (account is null) throw new InvalidOperationException("Account was not found");
|
||||||
|
|
||||||
|
session = await auth.CreateSessionForOidcAsync(account, now, client.Id);
|
||||||
|
scopes = authCode.Scopes;
|
||||||
|
}
|
||||||
|
else if (sessionId.HasValue)
|
||||||
|
{
|
||||||
|
// Refresh token flow
|
||||||
|
session = await FindSessionByIdAsync(sessionId.Value) ??
|
||||||
|
throw new InvalidOperationException("Invalid session");
|
||||||
|
|
||||||
|
// Verify the session is still valid
|
||||||
|
if (session.ExpiredAt < now)
|
||||||
|
throw new InvalidOperationException("Session has expired");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Either authorization code or session ID must be provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
|
||||||
|
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
|
||||||
|
|
||||||
|
// Generate an access token
|
||||||
|
var accessToken = GenerateJwtToken(client, session, expiresAt, scopes);
|
||||||
|
var refreshToken = GenerateRefreshToken(session);
|
||||||
|
|
||||||
|
return new TokenResponse
|
||||||
|
{
|
||||||
|
AccessToken = accessToken,
|
||||||
|
ExpiresIn = expiresIn,
|
||||||
|
TokenType = "Bearer",
|
||||||
|
RefreshToken = refreshToken,
|
||||||
|
Scope = scopes != null ? string.Join(" ", scopes) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GenerateJwtToken(
|
||||||
|
CustomApp client,
|
||||||
|
Session session,
|
||||||
|
Instant expiresAt,
|
||||||
|
IEnumerable<string>? scopes = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var tokenHandler = new JwtSecurityTokenHandler();
|
||||||
|
var clock = SystemClock.Instance;
|
||||||
|
var now = clock.GetCurrentInstant();
|
||||||
|
|
||||||
|
var tokenDescriptor = new SecurityTokenDescriptor
|
||||||
|
{
|
||||||
|
Subject = new ClaimsIdentity([
|
||||||
|
new Claim(JwtRegisteredClaimNames.Sub, session.AccountId.ToString()),
|
||||||
|
new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
|
||||||
|
new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
|
||||||
|
ClaimValueTypes.Integer64),
|
||||||
|
new Claim("client_id", client.Id.ToString())
|
||||||
|
]),
|
||||||
|
Expires = expiresAt.ToDateTimeUtc(),
|
||||||
|
Issuer = _options.IssuerUri,
|
||||||
|
Audience = client.Id.ToString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to use RSA signing if keys are available, fall back to HMAC
|
||||||
|
var rsaPrivateKey = _options.GetRsaPrivateKey();
|
||||||
|
tokenDescriptor.SigningCredentials = new SigningCredentials(
|
||||||
|
new RsaSecurityKey(rsaPrivateKey),
|
||||||
|
SecurityAlgorithms.RsaSha256
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add scopes as claims if provided
|
||||||
|
var effectiveScopes = scopes?.ToList() ?? client.OauthConfig!.AllowedScopes?.ToList() ?? [];
|
||||||
|
if (effectiveScopes.Count != 0)
|
||||||
|
{
|
||||||
|
tokenDescriptor.Subject.AddClaims(
|
||||||
|
effectiveScopes.Select(scope => new Claim("scope", scope)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = tokenHandler.CreateToken(tokenDescriptor);
|
||||||
|
return tokenHandler.WriteToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public (bool isValid, JwtSecurityToken? token) ValidateToken(string token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var tokenHandler = new JwtSecurityTokenHandler();
|
||||||
|
var validationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidIssuer = _options.IssuerUri,
|
||||||
|
ValidateAudience = false,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ClockSkew = TimeSpan.Zero
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to use RSA validation if public key is available
|
||||||
|
var rsaPublicKey = _options.GetRsaPublicKey();
|
||||||
|
validationParameters.IssuerSigningKey = new RsaSecurityKey(rsaPublicKey);
|
||||||
|
validationParameters.ValidateIssuerSigningKey = true;
|
||||||
|
validationParameters.ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 };
|
||||||
|
|
||||||
|
|
||||||
|
tokenHandler.ValidateToken(token, validationParameters, out var validatedToken);
|
||||||
|
return (true, (JwtSecurityToken)validatedToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Token validation failed");
|
||||||
|
return (false, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Session?> FindSessionByIdAsync(Guid sessionId)
|
||||||
|
{
|
||||||
|
return await db.AuthSessions
|
||||||
|
.Include(s => s.Account)
|
||||||
|
.Include(s => s.Challenge)
|
||||||
|
.Include(s => s.App)
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateRefreshToken(Session session)
|
||||||
|
{
|
||||||
|
return Convert.ToBase64String(session.Id.ToByteArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool VerifyHashedSecret(string secret, string hashedSecret)
|
||||||
|
{
|
||||||
|
// In a real implementation, you'd use a proper password hashing algorithm like PBKDF2, bcrypt, or Argon2
|
||||||
|
// For now, we'll do a simple comparison, but you should replace this with proper hashing
|
||||||
|
return string.Equals(secret, hashedSecret, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GenerateAuthorizationCodeForReuseSessionAsync(
|
||||||
|
Session session,
|
||||||
|
Guid clientId,
|
||||||
|
string redirectUri,
|
||||||
|
IEnumerable<string> scopes,
|
||||||
|
string? codeChallenge = null,
|
||||||
|
string? codeChallengeMethod = null,
|
||||||
|
string? nonce = null)
|
||||||
|
{
|
||||||
|
var clock = SystemClock.Instance;
|
||||||
|
var now = clock.GetCurrentInstant();
|
||||||
|
var code = Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
// Update the session's last activity time
|
||||||
|
await db.AuthSessions.Where(s => s.Id == session.Id)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(s => s.LastGrantedAt, now));
|
||||||
|
|
||||||
|
// Create the authorization code info
|
||||||
|
var authCodeInfo = new AuthorizationCodeInfo
|
||||||
|
{
|
||||||
|
ClientId = clientId,
|
||||||
|
AccountId = session.AccountId,
|
||||||
|
RedirectUri = redirectUri,
|
||||||
|
Scopes = scopes.ToList(),
|
||||||
|
CodeChallenge = codeChallenge,
|
||||||
|
CodeChallengeMethod = codeChallengeMethod,
|
||||||
|
Nonce = nonce,
|
||||||
|
CreatedAt = now
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store the code with its metadata in the cache
|
||||||
|
var cacheKey = $"auth:code:{code}";
|
||||||
|
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
|
||||||
|
|
||||||
|
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, session.AccountId);
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GenerateAuthorizationCodeAsync(
|
||||||
|
Guid clientId,
|
||||||
|
Guid userId,
|
||||||
|
string redirectUri,
|
||||||
|
IEnumerable<string> scopes,
|
||||||
|
string? codeChallenge = null,
|
||||||
|
string? codeChallengeMethod = null,
|
||||||
|
string? nonce = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// Generate a random code
|
||||||
|
var clock = SystemClock.Instance;
|
||||||
|
var code = GenerateRandomString(32);
|
||||||
|
var now = clock.GetCurrentInstant();
|
||||||
|
|
||||||
|
// Create the authorization code info
|
||||||
|
var authCodeInfo = new AuthorizationCodeInfo
|
||||||
|
{
|
||||||
|
ClientId = clientId,
|
||||||
|
AccountId = userId,
|
||||||
|
RedirectUri = redirectUri,
|
||||||
|
Scopes = scopes.ToList(),
|
||||||
|
CodeChallenge = codeChallenge,
|
||||||
|
CodeChallengeMethod = codeChallengeMethod,
|
||||||
|
Nonce = nonce,
|
||||||
|
CreatedAt = now
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store the code with its metadata in the cache
|
||||||
|
var cacheKey = $"auth:code:{code}";
|
||||||
|
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
|
||||||
|
|
||||||
|
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId);
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AuthorizationCodeInfo?> ValidateAuthorizationCodeAsync(
|
||||||
|
string code,
|
||||||
|
Guid clientId,
|
||||||
|
string? redirectUri = null,
|
||||||
|
string? codeVerifier = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var cacheKey = $"auth:code:{code}";
|
||||||
|
var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey);
|
||||||
|
|
||||||
|
if (!found || authCode == null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Authorization code not found: {Code}", code);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify client ID matches
|
||||||
|
if (authCode.ClientId != clientId)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Client ID mismatch for code {Code}. Expected: {ExpectedClientId}, Actual: {ActualClientId}",
|
||||||
|
code, authCode.ClientId, clientId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify redirect URI if provided
|
||||||
|
if (!string.IsNullOrEmpty(redirectUri) && authCode.RedirectUri != redirectUri)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Redirect URI mismatch for code {Code}", code);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify PKCE code challenge if one was provided during authorization
|
||||||
|
if (!string.IsNullOrEmpty(authCode.CodeChallenge))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(codeVerifier))
|
||||||
|
{
|
||||||
|
logger.LogWarning("PKCE code verifier is required but not provided for code {Code}", code);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isValid = authCode.CodeChallengeMethod?.ToUpperInvariant() switch
|
||||||
|
{
|
||||||
|
"S256" => VerifyCodeChallenge(codeVerifier, authCode.CodeChallenge, "S256"),
|
||||||
|
"PLAIN" => VerifyCodeChallenge(codeVerifier, authCode.CodeChallenge, "PLAIN"),
|
||||||
|
_ => false // Unsupported code challenge method
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isValid)
|
||||||
|
{
|
||||||
|
logger.LogWarning("PKCE code verifier validation failed for code {Code}", code);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code is valid, remove it from the cache (codes are single-use)
|
||||||
|
await cache.RemoveAsync(cacheKey);
|
||||||
|
|
||||||
|
return authCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateRandomString(int length)
|
||||||
|
{
|
||||||
|
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~";
|
||||||
|
var random = RandomNumberGenerator.Create();
|
||||||
|
var result = new char[length];
|
||||||
|
|
||||||
|
for (int i = 0; i < length; i++)
|
||||||
|
{
|
||||||
|
var randomNumber = new byte[4];
|
||||||
|
random.GetBytes(randomNumber);
|
||||||
|
var index = (int)(BitConverter.ToUInt32(randomNumber, 0) % chars.Length);
|
||||||
|
result[i] = chars[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new string(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool VerifyCodeChallenge(string codeVerifier, string codeChallenge, string method)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(codeVerifier)) return false;
|
||||||
|
|
||||||
|
if (method == "S256")
|
||||||
|
{
|
||||||
|
using var sha256 = SHA256.Create();
|
||||||
|
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
|
||||||
|
var base64 = Base64UrlEncoder.Encode(hash);
|
||||||
|
return string.Equals(base64, codeChallenge, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method == "PLAIN")
|
||||||
|
{
|
||||||
|
return string.Equals(codeVerifier, codeChallenge, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
95
DysonNetwork.Pass/Auth/OpenId/AfdianOidcService.cs
Normal file
95
DysonNetwork.Pass/Auth/OpenId/AfdianOidcService.cs
Normal file
@ -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<AfdianOidcService> logger
|
||||||
|
)
|
||||||
|
: OidcService(configuration, httpClientFactory, db, auth, cache)
|
||||||
|
{
|
||||||
|
public override string ProviderName => "Afdian";
|
||||||
|
protected override string DiscoveryEndpoint => ""; // Afdian doesn't have a standard OIDC discovery endpoint
|
||||||
|
protected override string ConfigSectionName => "Afdian";
|
||||||
|
|
||||||
|
public override string GetAuthorizationUrl(string state, string nonce)
|
||||||
|
{
|
||||||
|
var config = GetProviderConfig();
|
||||||
|
var queryParams = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "client_id", config.ClientId },
|
||||||
|
{ "redirect_uri", config.RedirectUri },
|
||||||
|
{ "response_type", "code" },
|
||||||
|
{ "scope", "basic" },
|
||||||
|
{ "state", state },
|
||||||
|
};
|
||||||
|
|
||||||
|
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
|
||||||
|
return $"https://afdian.com/oauth2/authorize?{queryString}";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync()
|
||||||
|
{
|
||||||
|
return Task.FromResult(new OidcDiscoveryDocument
|
||||||
|
{
|
||||||
|
AuthorizationEndpoint = "https://afdian.com/oauth2/authorize",
|
||||||
|
TokenEndpoint = "https://afdian.com/oauth2/access_token",
|
||||||
|
UserinfoEndpoint = null,
|
||||||
|
JwksUri = null
|
||||||
|
})!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var config = GetProviderConfig();
|
||||||
|
var content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "client_id", config.ClientId },
|
||||||
|
{ "client_secret", config.ClientSecret },
|
||||||
|
{ "grant_type", "authorization_code" },
|
||||||
|
{ "code", callbackData.Code },
|
||||||
|
{ "redirect_uri", config.RedirectUri },
|
||||||
|
});
|
||||||
|
|
||||||
|
var client = HttpClientFactory.CreateClient();
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/oauth2/access_token");
|
||||||
|
request.Content = content;
|
||||||
|
|
||||||
|
var response = await client.SendAsync(request);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
logger.LogInformation("Trying get userinfo from afdian, response: {Response}", json);
|
||||||
|
var afdianResponse = JsonDocument.Parse(json).RootElement;
|
||||||
|
|
||||||
|
var user = afdianResponse.TryGetProperty("data", out var dataElement) ? dataElement : default;
|
||||||
|
var userId = user.TryGetProperty("user_id", out var userIdElement) ? userIdElement.GetString() ?? "" : "";
|
||||||
|
var avatar = user.TryGetProperty("avatar", out var avatarElement) ? avatarElement.GetString() : null;
|
||||||
|
|
||||||
|
return new OidcUserInfo
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
DisplayName = (user.TryGetProperty("name", out var nameElement)
|
||||||
|
? nameElement.GetString()
|
||||||
|
: null) ?? "",
|
||||||
|
ProfilePictureUrl = avatar,
|
||||||
|
Provider = ProviderName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Due to afidan's API isn't compliant with OAuth2, we want more logs from it to investigate.
|
||||||
|
logger.LogError(ex, "Failed to get user info from Afdian");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
19
DysonNetwork.Pass/Auth/OpenId/AppleMobileSignInRequest.cs
Normal file
19
DysonNetwork.Pass/Auth/OpenId/AppleMobileSignInRequest.cs
Normal file
@ -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; }
|
||||||
|
}
|
280
DysonNetwork.Pass/Auth/OpenId/AppleOidcService.cs
Normal file
280
DysonNetwork.Pass/Auth/OpenId/AppleOidcService.cs
Normal file
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implementation of OpenID Connect service for Apple Sign In
|
||||||
|
/// </summary>
|
||||||
|
public class AppleOidcService(
|
||||||
|
IConfiguration configuration,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
AppDatabase db,
|
||||||
|
AuthService auth,
|
||||||
|
ICacheService cache
|
||||||
|
)
|
||||||
|
: OidcService(configuration, httpClientFactory, db, auth, cache)
|
||||||
|
{
|
||||||
|
private readonly IConfiguration _configuration = configuration;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
|
||||||
|
|
||||||
|
public override string ProviderName => "apple";
|
||||||
|
protected override string DiscoveryEndpoint => "https://appleid.apple.com/.well-known/openid-configuration";
|
||||||
|
protected override string ConfigSectionName => "Apple";
|
||||||
|
|
||||||
|
public override string GetAuthorizationUrl(string state, string nonce)
|
||||||
|
{
|
||||||
|
var config = GetProviderConfig();
|
||||||
|
|
||||||
|
var queryParams = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "client_id", config.ClientId },
|
||||||
|
{ "redirect_uri", config.RedirectUri },
|
||||||
|
{ "response_type", "code id_token" },
|
||||||
|
{ "scope", "name email" },
|
||||||
|
{ "response_mode", "form_post" },
|
||||||
|
{ "state", state },
|
||||||
|
{ "nonce", nonce }
|
||||||
|
};
|
||||||
|
|
||||||
|
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
|
||||||
|
return $"https://appleid.apple.com/auth/authorize?{queryString}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
|
||||||
|
{
|
||||||
|
// Verify and decode the id_token
|
||||||
|
var userInfo = await ValidateTokenAsync(callbackData.IdToken);
|
||||||
|
|
||||||
|
// If user data is provided in first login, parse it
|
||||||
|
if (!string.IsNullOrEmpty(callbackData.RawData))
|
||||||
|
{
|
||||||
|
var userData = JsonSerializer.Deserialize<AppleUserData>(callbackData.RawData);
|
||||||
|
if (userData?.Name != null)
|
||||||
|
{
|
||||||
|
userInfo.FirstName = userData.Name.FirstName ?? "";
|
||||||
|
userInfo.LastName = userData.Name.LastName ?? "";
|
||||||
|
userInfo.DisplayName = $"{userInfo.FirstName} {userInfo.LastName}".Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange authorization code for access token (optional, if you need the access token)
|
||||||
|
if (string.IsNullOrEmpty(callbackData.Code)) return userInfo;
|
||||||
|
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code);
|
||||||
|
if (tokenResponse == null) return userInfo;
|
||||||
|
userInfo.AccessToken = tokenResponse.AccessToken;
|
||||||
|
userInfo.RefreshToken = tokenResponse.RefreshToken;
|
||||||
|
|
||||||
|
return userInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OidcUserInfo> ValidateTokenAsync(string idToken)
|
||||||
|
{
|
||||||
|
// Get Apple's public keys
|
||||||
|
var jwksJson = await GetAppleJwksAsync();
|
||||||
|
var jwks = JsonSerializer.Deserialize<AppleJwks>(jwksJson) ?? new AppleJwks { Keys = new List<AppleKey>() };
|
||||||
|
|
||||||
|
// Parse the JWT header to get the key ID
|
||||||
|
var handler = new JwtSecurityTokenHandler();
|
||||||
|
var jwtToken = handler.ReadJwtToken(idToken);
|
||||||
|
var kid = jwtToken.Header.Kid;
|
||||||
|
|
||||||
|
// Find the matching key
|
||||||
|
var key = jwks.Keys.FirstOrDefault(k => k.Kid == kid);
|
||||||
|
if (key == null)
|
||||||
|
{
|
||||||
|
throw new SecurityTokenValidationException("Unable to find matching key in Apple's JWKS");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the validation parameters
|
||||||
|
var validationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidIssuer = "https://appleid.apple.com",
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidAudience = GetProviderConfig().ClientId,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
IssuerSigningKey = key.ToSecurityKey()
|
||||||
|
};
|
||||||
|
|
||||||
|
return ValidateAndExtractIdToken(idToken, validationParameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Dictionary<string, string> BuildTokenRequestParameters(
|
||||||
|
string code,
|
||||||
|
ProviderConfiguration config,
|
||||||
|
string? codeVerifier
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var parameters = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "client_id", config.ClientId },
|
||||||
|
{ "client_secret", GenerateClientSecret() },
|
||||||
|
{ "code", code },
|
||||||
|
{ "grant_type", "authorization_code" },
|
||||||
|
{ "redirect_uri", config.RedirectUri }
|
||||||
|
};
|
||||||
|
|
||||||
|
return parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GetAppleJwksAsync()
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient();
|
||||||
|
var response = await client.GetAsync("https://appleid.apple.com/auth/keys");
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
return await response.Content.ReadAsStringAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a client secret for Apple Sign In using JWT
|
||||||
|
/// </summary>
|
||||||
|
private string GenerateClientSecret()
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var teamId = _configuration["Oidc:Apple:TeamId"];
|
||||||
|
var clientId = _configuration["Oidc:Apple:ClientId"];
|
||||||
|
var keyId = _configuration["Oidc:Apple:KeyId"];
|
||||||
|
var privateKeyPath = _configuration["Oidc:Apple:PrivateKeyPath"];
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(teamId) || string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(keyId) ||
|
||||||
|
string.IsNullOrEmpty(privateKeyPath))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Apple OIDC configuration is missing required values (TeamId, ClientId, KeyId, PrivateKeyPath).");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the private key
|
||||||
|
var privateKey = File.ReadAllText(privateKeyPath);
|
||||||
|
|
||||||
|
// Create the JWT header
|
||||||
|
var header = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "alg", "ES256" },
|
||||||
|
{ "kid", keyId }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the JWT payload
|
||||||
|
var payload = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "iss", teamId },
|
||||||
|
{ "iat", ToUnixTimeSeconds(now) },
|
||||||
|
{ "exp", ToUnixTimeSeconds(now.AddMinutes(5)) },
|
||||||
|
{ "aud", "https://appleid.apple.com" },
|
||||||
|
{ "sub", clientId }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert header and payload to Base64Url
|
||||||
|
var headerJson = JsonSerializer.Serialize(header);
|
||||||
|
var payloadJson = JsonSerializer.Serialize(payload);
|
||||||
|
var headerBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson));
|
||||||
|
var payloadBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson));
|
||||||
|
|
||||||
|
// Create the signature
|
||||||
|
var dataToSign = $"{headerBase64}.{payloadBase64}";
|
||||||
|
var signature = SignWithECDsa(dataToSign, privateKey);
|
||||||
|
|
||||||
|
// Combine all parts
|
||||||
|
return $"{headerBase64}.{payloadBase64}.{signature}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private long ToUnixTimeSeconds(DateTime dateTime)
|
||||||
|
{
|
||||||
|
return new DateTimeOffset(dateTime).ToUnixTimeSeconds();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string SignWithECDsa(string dataToSign, string privateKey)
|
||||||
|
{
|
||||||
|
using var ecdsa = ECDsa.Create();
|
||||||
|
ecdsa.ImportFromPem(privateKey);
|
||||||
|
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(dataToSign);
|
||||||
|
var signature = ecdsa.SignData(bytes, HashAlgorithmName.SHA256);
|
||||||
|
|
||||||
|
return Base64UrlEncode(signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string Base64UrlEncode(byte[] data)
|
||||||
|
{
|
||||||
|
return Convert.ToBase64String(data)
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_')
|
||||||
|
.TrimEnd('=');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AppleUserData
|
||||||
|
{
|
||||||
|
[JsonPropertyName("name")] public AppleNameData? Name { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("email")] public string? Email { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AppleNameData
|
||||||
|
{
|
||||||
|
[JsonPropertyName("firstName")] public string? FirstName { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("lastName")] public string? LastName { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AppleJwks
|
||||||
|
{
|
||||||
|
[JsonPropertyName("keys")] public List<AppleKey> Keys { get; set; } = new List<AppleKey>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AppleKey
|
||||||
|
{
|
||||||
|
[JsonPropertyName("kty")] public string? Kty { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("kid")] public string? Kid { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("use")] public string? Use { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("alg")] public string? Alg { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("n")] public string? N { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("e")] public string? E { get; set; }
|
||||||
|
|
||||||
|
public SecurityKey ToSecurityKey()
|
||||||
|
{
|
||||||
|
if (Kty != "RSA" || string.IsNullOrEmpty(N) || string.IsNullOrEmpty(E))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Invalid key data");
|
||||||
|
}
|
||||||
|
|
||||||
|
var parameters = new RSAParameters
|
||||||
|
{
|
||||||
|
Modulus = Base64UrlDecode(N),
|
||||||
|
Exponent = Base64UrlDecode(E)
|
||||||
|
};
|
||||||
|
|
||||||
|
var rsa = RSA.Create();
|
||||||
|
rsa.ImportParameters(parameters);
|
||||||
|
|
||||||
|
return new RsaSecurityKey(rsa);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] Base64UrlDecode(string input)
|
||||||
|
{
|
||||||
|
var output = input
|
||||||
|
.Replace('-', '+')
|
||||||
|
.Replace('_', '/');
|
||||||
|
|
||||||
|
switch (output.Length % 4)
|
||||||
|
{
|
||||||
|
case 0: break;
|
||||||
|
case 2: output += "=="; break;
|
||||||
|
case 3: output += "="; break;
|
||||||
|
default: throw new InvalidOperationException("Invalid base64url string");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Convert.FromBase64String(output);
|
||||||
|
}
|
||||||
|
}
|
409
DysonNetwork.Pass/Auth/OpenId/ConnectionController.cs
Normal file
409
DysonNetwork.Pass/Auth/OpenId/ConnectionController.cs
Normal file
@ -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<OidcService> oidcServices,
|
||||||
|
AccountService accounts,
|
||||||
|
AuthService auth,
|
||||||
|
ICacheService cache
|
||||||
|
) : ControllerBase
|
||||||
|
{
|
||||||
|
private const string StateCachePrefix = "oidc-state:";
|
||||||
|
private const string ReturnUrlCachePrefix = "oidc-returning:";
|
||||||
|
private static readonly TimeSpan StateExpiration = TimeSpan.FromMinutes(15);
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<List<AccountConnection>>> GetConnections()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var connections = await db.AccountConnections
|
||||||
|
.Where(c => c.AccountId == currentUser.Id)
|
||||||
|
.Select(c => new
|
||||||
|
{
|
||||||
|
c.Id,
|
||||||
|
c.AccountId,
|
||||||
|
c.Provider,
|
||||||
|
c.ProvidedIdentifier,
|
||||||
|
c.Meta,
|
||||||
|
c.LastUsedAt,
|
||||||
|
c.CreatedAt,
|
||||||
|
c.UpdatedAt,
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
return Ok(connections);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}")]
|
||||||
|
public async Task<ActionResult> RemoveConnection(Guid id)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var connection = await db.AccountConnections
|
||||||
|
.Where(c => c.Id == id && c.AccountId == currentUser.Id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (connection == null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
db.AccountConnections.Remove(connection);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("/auth/connect/apple/mobile")]
|
||||||
|
public async Task<ActionResult> ConnectAppleMobile([FromBody] AppleMobileConnectRequest request)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
if (GetOidcService("apple") is not AppleOidcService appleService)
|
||||||
|
return StatusCode(503, "Apple OIDC service not available");
|
||||||
|
|
||||||
|
var callbackData = new OidcCallbackData
|
||||||
|
{
|
||||||
|
IdToken = request.IdentityToken,
|
||||||
|
Code = request.AuthorizationCode,
|
||||||
|
};
|
||||||
|
|
||||||
|
OidcUserInfo userInfo;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
userInfo = await appleService.ProcessCallbackAsync(callbackData);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest($"Error processing Apple token: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingConnection = await db.AccountConnections
|
||||||
|
.FirstOrDefaultAsync(c =>
|
||||||
|
c.Provider == "apple" &&
|
||||||
|
c.ProvidedIdentifier == userInfo.UserId);
|
||||||
|
|
||||||
|
if (existingConnection != null)
|
||||||
|
{
|
||||||
|
return BadRequest(
|
||||||
|
$"This Apple account is already linked to {(existingConnection.AccountId == currentUser.Id ? "your account" : "another user")}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
db.AccountConnections.Add(new AccountConnection
|
||||||
|
{
|
||||||
|
AccountId = currentUser.Id,
|
||||||
|
Provider = "apple",
|
||||||
|
ProvidedIdentifier = userInfo.UserId!,
|
||||||
|
AccessToken = userInfo.AccessToken,
|
||||||
|
RefreshToken = userInfo.RefreshToken,
|
||||||
|
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
|
||||||
|
Meta = userInfo.ToMetadata(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new { message = "Successfully connected Apple account." });
|
||||||
|
}
|
||||||
|
|
||||||
|
private OidcService? GetOidcService(string provider)
|
||||||
|
{
|
||||||
|
return oidcServices.FirstOrDefault(s => s.ProviderName.Equals(provider, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ConnectProviderRequest
|
||||||
|
{
|
||||||
|
public string Provider { get; set; } = null!;
|
||||||
|
public string? ReturnUrl { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initiates manual connection to an OAuth provider for the current user
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("connect")]
|
||||||
|
public async Task<ActionResult<object>> InitiateConnection([FromBody] ConnectProviderRequest request)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser)
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var oidcService = GetOidcService(request.Provider);
|
||||||
|
if (oidcService == null)
|
||||||
|
return BadRequest($"Provider '{request.Provider}' is not supported");
|
||||||
|
|
||||||
|
var existingConnection = await db.AccountConnections
|
||||||
|
.AnyAsync(c => c.AccountId == currentUser.Id && c.Provider == oidcService.ProviderName);
|
||||||
|
|
||||||
|
if (existingConnection)
|
||||||
|
return BadRequest($"You already have a {request.Provider} connection");
|
||||||
|
|
||||||
|
var state = Guid.NewGuid().ToString("N");
|
||||||
|
var nonce = Guid.NewGuid().ToString("N");
|
||||||
|
var stateValue = $"{currentUser.Id}|{request.Provider}|{nonce}";
|
||||||
|
var finalReturnUrl = !string.IsNullOrEmpty(request.ReturnUrl) ? request.ReturnUrl : "/settings/connections";
|
||||||
|
|
||||||
|
// Store state and return URL in cache
|
||||||
|
await cache.SetAsync($"{StateCachePrefix}{state}", stateValue, StateExpiration);
|
||||||
|
await cache.SetAsync($"{ReturnUrlCachePrefix}{state}", finalReturnUrl, StateExpiration);
|
||||||
|
|
||||||
|
var authUrl = oidcService.GetAuthorizationUrl(state, nonce);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
authUrl,
|
||||||
|
message = $"Redirect to this URL to connect your {request.Provider} account"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[Route("/api/auth/callback/{provider}")]
|
||||||
|
[HttpGet, HttpPost]
|
||||||
|
public async Task<IActionResult> HandleCallback([FromRoute] string provider)
|
||||||
|
{
|
||||||
|
var oidcService = GetOidcService(provider);
|
||||||
|
if (oidcService == null)
|
||||||
|
return BadRequest($"Provider '{provider}' is not supported.");
|
||||||
|
|
||||||
|
var callbackData = await ExtractCallbackData(Request);
|
||||||
|
if (callbackData.State == null)
|
||||||
|
return BadRequest("State parameter is missing.");
|
||||||
|
|
||||||
|
// Get the state from the cache
|
||||||
|
var stateKey = $"{StateCachePrefix}{callbackData.State}";
|
||||||
|
|
||||||
|
// Try to get the state as OidcState first (new format)
|
||||||
|
var oidcState = await cache.GetAsync<OidcState>(stateKey);
|
||||||
|
|
||||||
|
// If not found, try to get as string (legacy format)
|
||||||
|
if (oidcState == null)
|
||||||
|
{
|
||||||
|
var stateValue = await cache.GetAsync<string>(stateKey);
|
||||||
|
if (string.IsNullOrEmpty(stateValue) || !OidcState.TryParse(stateValue, out oidcState) || oidcState == null)
|
||||||
|
return BadRequest("Invalid or expired state parameter");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the state from cache to prevent replay attacks
|
||||||
|
await cache.RemoveAsync(stateKey);
|
||||||
|
|
||||||
|
// Handle the flow based on state type
|
||||||
|
if (oidcState.FlowType == OidcFlowType.Connect && oidcState.AccountId.HasValue)
|
||||||
|
{
|
||||||
|
// Connection flow
|
||||||
|
if (oidcState.DeviceId != null)
|
||||||
|
{
|
||||||
|
callbackData.State = oidcState.DeviceId;
|
||||||
|
}
|
||||||
|
return await HandleManualConnection(provider, oidcService, callbackData, oidcState.AccountId.Value);
|
||||||
|
}
|
||||||
|
else if (oidcState.FlowType == OidcFlowType.Login)
|
||||||
|
{
|
||||||
|
// Login/Registration flow
|
||||||
|
if (!string.IsNullOrEmpty(oidcState.DeviceId))
|
||||||
|
{
|
||||||
|
callbackData.State = oidcState.DeviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store return URL if provided
|
||||||
|
if (!string.IsNullOrEmpty(oidcState.ReturnUrl) && oidcState.ReturnUrl != "/")
|
||||||
|
{
|
||||||
|
var returnUrlKey = $"{ReturnUrlCachePrefix}{callbackData.State}";
|
||||||
|
await cache.SetAsync(returnUrlKey, oidcState.ReturnUrl, StateExpiration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await HandleLoginOrRegistration(provider, oidcService, callbackData);
|
||||||
|
}
|
||||||
|
|
||||||
|
return BadRequest("Unsupported flow type");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult> HandleManualConnection(
|
||||||
|
string provider,
|
||||||
|
OidcService oidcService,
|
||||||
|
OidcCallbackData callbackData,
|
||||||
|
Guid accountId
|
||||||
|
)
|
||||||
|
{
|
||||||
|
provider = provider.ToLower();
|
||||||
|
|
||||||
|
OidcUserInfo userInfo;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
userInfo = await oidcService.ProcessCallbackAsync(callbackData);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest($"Error processing {provider} authentication: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(userInfo.UserId))
|
||||||
|
{
|
||||||
|
return BadRequest($"{provider} did not return a valid user identifier.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract device ID from the callback state if available
|
||||||
|
var deviceId = !string.IsNullOrEmpty(callbackData.State) ? callbackData.State : string.Empty;
|
||||||
|
|
||||||
|
// Check if this provider account is already connected to any user
|
||||||
|
var existingConnection = await db.AccountConnections
|
||||||
|
.FirstOrDefaultAsync(c =>
|
||||||
|
c.Provider == provider &&
|
||||||
|
c.ProvidedIdentifier == userInfo.UserId);
|
||||||
|
|
||||||
|
// If it's connected to a different user, return error
|
||||||
|
if (existingConnection != null && existingConnection.AccountId != accountId)
|
||||||
|
{
|
||||||
|
return BadRequest($"This {provider} account is already linked to another user.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the current user already has this provider connected
|
||||||
|
var userHasProvider = await db.AccountConnections
|
||||||
|
.AnyAsync(c =>
|
||||||
|
c.AccountId == accountId &&
|
||||||
|
c.Provider == provider);
|
||||||
|
|
||||||
|
if (userHasProvider)
|
||||||
|
{
|
||||||
|
// Update existing connection with new tokens
|
||||||
|
var connection = await db.AccountConnections
|
||||||
|
.FirstOrDefaultAsync(c =>
|
||||||
|
c.AccountId == accountId &&
|
||||||
|
c.Provider == provider);
|
||||||
|
|
||||||
|
if (connection != null)
|
||||||
|
{
|
||||||
|
connection.AccessToken = userInfo.AccessToken;
|
||||||
|
connection.RefreshToken = userInfo.RefreshToken;
|
||||||
|
connection.LastUsedAt = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
connection.Meta = userInfo.ToMetadata();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Create new connection
|
||||||
|
db.AccountConnections.Add(new AccountConnection
|
||||||
|
{
|
||||||
|
AccountId = accountId,
|
||||||
|
Provider = provider,
|
||||||
|
ProvidedIdentifier = userInfo.UserId!,
|
||||||
|
AccessToken = userInfo.AccessToken,
|
||||||
|
RefreshToken = userInfo.RefreshToken,
|
||||||
|
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
|
||||||
|
Meta = userInfo.ToMetadata(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
return StatusCode(500, $"Failed to save {provider} connection. Please try again.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up and redirect
|
||||||
|
var returnUrlKey = $"{ReturnUrlCachePrefix}{callbackData.State}";
|
||||||
|
var returnUrl = await cache.GetAsync<string>(returnUrlKey);
|
||||||
|
await cache.RemoveAsync(returnUrlKey);
|
||||||
|
|
||||||
|
return Redirect(string.IsNullOrEmpty(returnUrl) ? "/auth/callback" : returnUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult> HandleLoginOrRegistration(
|
||||||
|
string provider,
|
||||||
|
OidcService oidcService,
|
||||||
|
OidcCallbackData callbackData
|
||||||
|
)
|
||||||
|
{
|
||||||
|
OidcUserInfo userInfo;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
userInfo = await oidcService.ProcessCallbackAsync(callbackData);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest($"Error processing callback: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(userInfo.Email) || string.IsNullOrEmpty(userInfo.UserId))
|
||||||
|
{
|
||||||
|
return BadRequest($"Email or user ID is missing from {provider}'s response");
|
||||||
|
}
|
||||||
|
|
||||||
|
var connection = await db.AccountConnections
|
||||||
|
.Include(c => c.Account)
|
||||||
|
.FirstOrDefaultAsync(c => c.Provider == provider && c.ProvidedIdentifier == userInfo.UserId);
|
||||||
|
|
||||||
|
var clock = SystemClock.Instance;
|
||||||
|
if (connection != null)
|
||||||
|
{
|
||||||
|
// Login existing user
|
||||||
|
var deviceId = !string.IsNullOrEmpty(callbackData.State) ?
|
||||||
|
callbackData.State.Split('|').FirstOrDefault() :
|
||||||
|
string.Empty;
|
||||||
|
|
||||||
|
var challenge = await oidcService.CreateChallengeForUserAsync(
|
||||||
|
userInfo,
|
||||||
|
connection.Account,
|
||||||
|
HttpContext,
|
||||||
|
deviceId ?? string.Empty);
|
||||||
|
return Redirect($"/auth/callback?challenge={challenge.Id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register new user
|
||||||
|
var account = await accounts.LookupAccount(userInfo.Email) ?? await accounts.CreateAccount(userInfo);
|
||||||
|
|
||||||
|
// Create connection for new or existing user
|
||||||
|
var newConnection = new AccountConnection
|
||||||
|
{
|
||||||
|
Account = account,
|
||||||
|
Provider = provider,
|
||||||
|
ProvidedIdentifier = userInfo.UserId!,
|
||||||
|
AccessToken = userInfo.AccessToken,
|
||||||
|
RefreshToken = userInfo.RefreshToken,
|
||||||
|
LastUsedAt = clock.GetCurrentInstant(),
|
||||||
|
Meta = userInfo.ToMetadata()
|
||||||
|
};
|
||||||
|
db.AccountConnections.Add(newConnection);
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant());
|
||||||
|
var loginToken = auth.CreateToken(loginSession);
|
||||||
|
return Redirect($"/auth/token?token={loginToken}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request)
|
||||||
|
{
|
||||||
|
var data = new OidcCallbackData();
|
||||||
|
switch (request.Method)
|
||||||
|
{
|
||||||
|
case "GET":
|
||||||
|
data.Code = Uri.UnescapeDataString(request.Query["code"].FirstOrDefault() ?? "");
|
||||||
|
data.IdToken = Uri.UnescapeDataString(request.Query["id_token"].FirstOrDefault() ?? "");
|
||||||
|
data.State = Uri.UnescapeDataString(request.Query["state"].FirstOrDefault() ?? "");
|
||||||
|
break;
|
||||||
|
case "POST" when request.HasFormContentType:
|
||||||
|
{
|
||||||
|
var form = await request.ReadFormAsync();
|
||||||
|
data.Code = Uri.UnescapeDataString(form["code"].FirstOrDefault() ?? "");
|
||||||
|
data.IdToken = Uri.UnescapeDataString(form["id_token"].FirstOrDefault() ?? "");
|
||||||
|
data.State = Uri.UnescapeDataString(form["state"].FirstOrDefault() ?? "");
|
||||||
|
if (form.ContainsKey("user"))
|
||||||
|
data.RawData = Uri.UnescapeDataString(form["user"].FirstOrDefault() ?? "");
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
116
DysonNetwork.Pass/Auth/OpenId/DiscordOidcService.cs
Normal file
116
DysonNetwork.Pass/Auth/OpenId/DiscordOidcService.cs
Normal file
@ -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<string, string>
|
||||||
|
{
|
||||||
|
{ "client_id", config.ClientId },
|
||||||
|
{ "redirect_uri", config.RedirectUri },
|
||||||
|
{ "response_type", "code" },
|
||||||
|
{ "scope", "identify email" },
|
||||||
|
{ "state", state },
|
||||||
|
};
|
||||||
|
|
||||||
|
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
|
||||||
|
return $"https://discord.com/oauth2/authorize?{queryString}";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync()
|
||||||
|
{
|
||||||
|
return Task.FromResult(new OidcDiscoveryDocument
|
||||||
|
{
|
||||||
|
AuthorizationEndpoint = "https://discord.com/oauth2/authorize",
|
||||||
|
TokenEndpoint = "https://discord.com/oauth2/token",
|
||||||
|
UserinfoEndpoint = "https://discord.com/users/@me",
|
||||||
|
JwksUri = null
|
||||||
|
})!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
|
||||||
|
{
|
||||||
|
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code);
|
||||||
|
if (tokenResponse?.AccessToken == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Failed to obtain access token from Discord");
|
||||||
|
}
|
||||||
|
|
||||||
|
var userInfo = await GetUserInfoAsync(tokenResponse.AccessToken);
|
||||||
|
|
||||||
|
userInfo.AccessToken = tokenResponse.AccessToken;
|
||||||
|
userInfo.RefreshToken = tokenResponse.RefreshToken;
|
||||||
|
|
||||||
|
return userInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code,
|
||||||
|
string? codeVerifier = null)
|
||||||
|
{
|
||||||
|
var config = GetProviderConfig();
|
||||||
|
var client = HttpClientFactory.CreateClient();
|
||||||
|
|
||||||
|
var content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "client_id", config.ClientId },
|
||||||
|
{ "client_secret", config.ClientSecret },
|
||||||
|
{ "grant_type", "authorization_code" },
|
||||||
|
{ "code", code },
|
||||||
|
{ "redirect_uri", config.RedirectUri },
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await client.PostAsync("https://discord.com/oauth2/token", content);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken)
|
||||||
|
{
|
||||||
|
var client = HttpClientFactory.CreateClient();
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, "https://discord.com/users/@me");
|
||||||
|
request.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||||
|
|
||||||
|
var response = await client.SendAsync(request);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var discordUser = JsonDocument.Parse(json).RootElement;
|
||||||
|
|
||||||
|
var userId = discordUser.GetProperty("id").GetString() ?? "";
|
||||||
|
var avatar = discordUser.TryGetProperty("avatar", out var avatarElement) ? avatarElement.GetString() : null;
|
||||||
|
|
||||||
|
return new OidcUserInfo
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
Email = (discordUser.TryGetProperty("email", out var emailElement) ? emailElement.GetString() : null) ?? "",
|
||||||
|
EmailVerified = discordUser.TryGetProperty("verified", out var verifiedElement) &&
|
||||||
|
verifiedElement.GetBoolean(),
|
||||||
|
DisplayName = (discordUser.TryGetProperty("global_name", out var globalNameElement)
|
||||||
|
? globalNameElement.GetString()
|
||||||
|
: null) ?? "",
|
||||||
|
PreferredUsername = discordUser.GetProperty("username").GetString() ?? "",
|
||||||
|
ProfilePictureUrl = !string.IsNullOrEmpty(avatar)
|
||||||
|
? $"https://cdn.discordapp.com/avatars/{userId}/{avatar}.png"
|
||||||
|
: "",
|
||||||
|
Provider = ProviderName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
128
DysonNetwork.Pass/Auth/OpenId/GitHubOidcService.cs
Normal file
128
DysonNetwork.Pass/Auth/OpenId/GitHubOidcService.cs
Normal file
@ -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<string, string>
|
||||||
|
{
|
||||||
|
{ "client_id", config.ClientId },
|
||||||
|
{ "redirect_uri", config.RedirectUri },
|
||||||
|
{ "scope", "user:email" },
|
||||||
|
{ "state", state },
|
||||||
|
};
|
||||||
|
|
||||||
|
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
|
||||||
|
return $"https://github.com/login/oauth/authorize?{queryString}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
|
||||||
|
{
|
||||||
|
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code);
|
||||||
|
if (tokenResponse?.AccessToken == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Failed to obtain access token from GitHub");
|
||||||
|
}
|
||||||
|
|
||||||
|
var userInfo = await GetUserInfoAsync(tokenResponse.AccessToken);
|
||||||
|
|
||||||
|
userInfo.AccessToken = tokenResponse.AccessToken;
|
||||||
|
userInfo.RefreshToken = tokenResponse.RefreshToken;
|
||||||
|
|
||||||
|
return userInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code,
|
||||||
|
string? codeVerifier = null)
|
||||||
|
{
|
||||||
|
var config = GetProviderConfig();
|
||||||
|
var client = HttpClientFactory.CreateClient();
|
||||||
|
|
||||||
|
var tokenRequest = new HttpRequestMessage(HttpMethod.Post, "https://github.com/login/oauth/access_token")
|
||||||
|
{
|
||||||
|
Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "client_id", config.ClientId },
|
||||||
|
{ "client_secret", config.ClientSecret },
|
||||||
|
{ "code", code },
|
||||||
|
{ "redirect_uri", config.RedirectUri },
|
||||||
|
})
|
||||||
|
};
|
||||||
|
tokenRequest.Headers.Add("Accept", "application/json");
|
||||||
|
|
||||||
|
var response = await client.SendAsync(tokenRequest);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken)
|
||||||
|
{
|
||||||
|
var client = HttpClientFactory.CreateClient();
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user");
|
||||||
|
request.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||||
|
request.Headers.Add("User-Agent", "DysonNetwork.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<string?> GetPrimaryEmailAsync(string accessToken)
|
||||||
|
{
|
||||||
|
var client = HttpClientFactory.CreateClient();
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user/emails");
|
||||||
|
request.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||||
|
request.Headers.Add("User-Agent", "DysonNetwork.Pass");
|
||||||
|
|
||||||
|
var response = await client.SendAsync(request);
|
||||||
|
if (!response.IsSuccessStatusCode) return null;
|
||||||
|
|
||||||
|
var emails = await response.Content.ReadFromJsonAsync<List<GitHubEmail>>();
|
||||||
|
return emails?.FirstOrDefault(e => e.Primary)?.Email;
|
||||||
|
}
|
||||||
|
|
||||||
|
private class GitHubEmail
|
||||||
|
{
|
||||||
|
public string Email { get; set; } = "";
|
||||||
|
public bool Primary { get; set; }
|
||||||
|
public bool Verified { get; set; }
|
||||||
|
}
|
||||||
|
}
|
137
DysonNetwork.Pass/Auth/OpenId/GoogleOidcService.cs
Normal file
137
DysonNetwork.Pass/Auth/OpenId/GoogleOidcService.cs
Normal file
@ -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<string, string>
|
||||||
|
{
|
||||||
|
{ "client_id", config.ClientId },
|
||||||
|
{ "redirect_uri", config.RedirectUri },
|
||||||
|
{ "response_type", "code" },
|
||||||
|
{ "scope", "openid email profile" },
|
||||||
|
{ "state", state }, // No '|codeVerifier' appended anymore
|
||||||
|
{ "nonce", nonce }
|
||||||
|
};
|
||||||
|
|
||||||
|
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
|
||||||
|
return $"{discoveryDocument.AuthorizationEndpoint}?{queryString}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
|
||||||
|
{
|
||||||
|
// No need to split or parse code verifier from state
|
||||||
|
var state = callbackData.State ?? "";
|
||||||
|
callbackData.State = state; // Keep the original state if needed
|
||||||
|
|
||||||
|
// Exchange the code for tokens
|
||||||
|
// Pass null or omit the parameter for codeVerifier as PKCE is removed
|
||||||
|
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code, null);
|
||||||
|
if (tokenResponse?.IdToken == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Failed to obtain ID token from Google");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the ID token
|
||||||
|
var userInfo = await ValidateTokenAsync(tokenResponse.IdToken);
|
||||||
|
|
||||||
|
// Set tokens on the user info
|
||||||
|
userInfo.AccessToken = tokenResponse.AccessToken;
|
||||||
|
userInfo.RefreshToken = tokenResponse.RefreshToken;
|
||||||
|
|
||||||
|
// Try to fetch additional profile data if userinfo endpoint is available
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var discoveryDocument = await GetDiscoveryDocumentAsync();
|
||||||
|
if (discoveryDocument?.UserinfoEndpoint != null && !string.IsNullOrEmpty(tokenResponse.AccessToken))
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient();
|
||||||
|
client.DefaultRequestHeaders.Authorization =
|
||||||
|
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken);
|
||||||
|
|
||||||
|
var userInfoResponse =
|
||||||
|
await client.GetFromJsonAsync<Dictionary<string, object>>(discoveryDocument.UserinfoEndpoint);
|
||||||
|
|
||||||
|
if (userInfoResponse != null)
|
||||||
|
{
|
||||||
|
if (userInfoResponse.TryGetValue("picture", out var picture) && picture != null)
|
||||||
|
{
|
||||||
|
userInfo.ProfilePictureUrl = picture.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore errors when fetching additional profile data
|
||||||
|
}
|
||||||
|
|
||||||
|
return userInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OidcUserInfo> ValidateTokenAsync(string idToken)
|
||||||
|
{
|
||||||
|
var discoveryDocument = await GetDiscoveryDocumentAsync();
|
||||||
|
if (discoveryDocument?.JwksUri == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("JWKS URI not found in discovery document");
|
||||||
|
}
|
||||||
|
|
||||||
|
var client = _httpClientFactory.CreateClient();
|
||||||
|
var jwksResponse = await client.GetFromJsonAsync<JsonWebKeySet>(discoveryDocument.JwksUri);
|
||||||
|
if (jwksResponse == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Failed to retrieve JWKS from Google");
|
||||||
|
}
|
||||||
|
|
||||||
|
var handler = new JwtSecurityTokenHandler();
|
||||||
|
var jwtToken = handler.ReadJwtToken(idToken);
|
||||||
|
var kid = jwtToken.Header.Kid;
|
||||||
|
var signingKey = jwksResponse.Keys.FirstOrDefault(k => k.Kid == kid);
|
||||||
|
if (signingKey == null)
|
||||||
|
{
|
||||||
|
throw new SecurityTokenValidationException("Unable to find matching key in Google's JWKS");
|
||||||
|
}
|
||||||
|
|
||||||
|
var validationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidIssuer = "https://accounts.google.com",
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidAudience = GetProviderConfig().ClientId,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
IssuerSigningKey = signingKey
|
||||||
|
};
|
||||||
|
|
||||||
|
return ValidateAndExtractIdToken(idToken, validationParameters);
|
||||||
|
}
|
||||||
|
}
|
123
DysonNetwork.Pass/Auth/OpenId/MicrosoftOidcService.cs
Normal file
123
DysonNetwork.Pass/Auth/OpenId/MicrosoftOidcService.cs
Normal file
@ -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<string, string>
|
||||||
|
{
|
||||||
|
{ "client_id", config.ClientId },
|
||||||
|
{ "response_type", "code" },
|
||||||
|
{ "redirect_uri", config.RedirectUri },
|
||||||
|
{ "response_mode", "query" },
|
||||||
|
{ "scope", "openid profile email" },
|
||||||
|
{ "state", state },
|
||||||
|
{ "nonce", nonce },
|
||||||
|
};
|
||||||
|
|
||||||
|
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
|
||||||
|
return $"{discoveryDocument.AuthorizationEndpoint}?{queryString}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
|
||||||
|
{
|
||||||
|
var tokenResponse = await ExchangeCodeForTokensAsync(callbackData.Code);
|
||||||
|
if (tokenResponse?.AccessToken == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Failed to obtain access token from Microsoft");
|
||||||
|
}
|
||||||
|
|
||||||
|
var userInfo = await GetUserInfoAsync(tokenResponse.AccessToken);
|
||||||
|
|
||||||
|
userInfo.AccessToken = tokenResponse.AccessToken;
|
||||||
|
userInfo.RefreshToken = tokenResponse.RefreshToken;
|
||||||
|
|
||||||
|
return userInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code,
|
||||||
|
string? codeVerifier = null)
|
||||||
|
{
|
||||||
|
var config = GetProviderConfig();
|
||||||
|
var discoveryDocument = await GetDiscoveryDocumentAsync();
|
||||||
|
if (discoveryDocument?.TokenEndpoint == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Token endpoint not found in discovery document.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var client = HttpClientFactory.CreateClient();
|
||||||
|
|
||||||
|
var tokenRequest = new HttpRequestMessage(HttpMethod.Post, discoveryDocument.TokenEndpoint)
|
||||||
|
{
|
||||||
|
Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "client_id", config.ClientId },
|
||||||
|
{ "scope", "openid profile email" },
|
||||||
|
{ "code", code },
|
||||||
|
{ "redirect_uri", config.RedirectUri },
|
||||||
|
{ "grant_type", "authorization_code" },
|
||||||
|
{ "client_secret", config.ClientSecret },
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await client.SendAsync(tokenRequest);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken)
|
||||||
|
{
|
||||||
|
var discoveryDocument = await GetDiscoveryDocumentAsync();
|
||||||
|
if (discoveryDocument?.UserinfoEndpoint == null)
|
||||||
|
throw new InvalidOperationException("Userinfo endpoint not found in discovery document.");
|
||||||
|
|
||||||
|
var client = HttpClientFactory.CreateClient();
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, discoveryDocument.UserinfoEndpoint);
|
||||||
|
request.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||||
|
|
||||||
|
var response = await client.SendAsync(request);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var microsoftUser = JsonDocument.Parse(json).RootElement;
|
||||||
|
|
||||||
|
return new OidcUserInfo
|
||||||
|
{
|
||||||
|
UserId = microsoftUser.GetProperty("sub").GetString() ?? "",
|
||||||
|
Email = microsoftUser.TryGetProperty("email", out var emailElement) ? emailElement.GetString() : null,
|
||||||
|
DisplayName =
|
||||||
|
microsoftUser.TryGetProperty("name", out var nameElement) ? nameElement.GetString() ?? "" : "",
|
||||||
|
PreferredUsername = microsoftUser.TryGetProperty("preferred_username", out var preferredUsernameElement)
|
||||||
|
? preferredUsernameElement.GetString() ?? ""
|
||||||
|
: "",
|
||||||
|
ProfilePictureUrl = microsoftUser.TryGetProperty("picture", out var pictureElement)
|
||||||
|
? pictureElement.GetString() ?? ""
|
||||||
|
: "",
|
||||||
|
Provider = ProviderName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
194
DysonNetwork.Pass/Auth/OpenId/OidcController.cs
Normal file
194
DysonNetwork.Pass/Auth/OpenId/OidcController.cs
Normal file
@ -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<ActionResult> OidcLogin(
|
||||||
|
[FromRoute] string provider,
|
||||||
|
[FromQuery] string? returnUrl = "/",
|
||||||
|
[FromHeader(Name = "X-Device-Id")] string? deviceId = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var oidcService = GetOidcService(provider);
|
||||||
|
|
||||||
|
// If the user is already authenticated, treat as an account connection request
|
||||||
|
if (HttpContext.Items["CurrentUser"] is Account.Account currentUser)
|
||||||
|
{
|
||||||
|
var state = Guid.NewGuid().ToString();
|
||||||
|
var nonce = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
// Create and store connection state
|
||||||
|
var oidcState = OidcState.ForConnection(currentUser.Id, provider, nonce, deviceId);
|
||||||
|
await cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration);
|
||||||
|
|
||||||
|
// The state parameter sent to the provider is the GUID key for the cache.
|
||||||
|
var authUrl = oidcService.GetAuthorizationUrl(state, nonce);
|
||||||
|
return Redirect(authUrl);
|
||||||
|
}
|
||||||
|
else // Otherwise, proceed with the login / registration flow
|
||||||
|
{
|
||||||
|
var nonce = Guid.NewGuid().ToString();
|
||||||
|
var state = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
// Create login state with return URL and device ID
|
||||||
|
var oidcState = OidcState.ForLogin(returnUrl ?? "/", deviceId);
|
||||||
|
await cache.SetAsync($"{StateCachePrefix}{state}", oidcState, StateExpiration);
|
||||||
|
var authUrl = oidcService.GetAuthorizationUrl(state, nonce);
|
||||||
|
return Redirect(authUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest($"Error initiating OpenID Connect flow: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mobile Apple Sign In endpoint
|
||||||
|
/// Handles Apple authentication directly from mobile apps
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("apple/mobile")]
|
||||||
|
public async Task<ActionResult<Challenge>> AppleMobileLogin(
|
||||||
|
[FromBody] AppleMobileSignInRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get Apple OIDC service
|
||||||
|
if (GetOidcService("apple") is not AppleOidcService appleService)
|
||||||
|
return StatusCode(503, "Apple OIDC service not available");
|
||||||
|
|
||||||
|
// Prepare callback data for processing
|
||||||
|
var callbackData = new OidcCallbackData
|
||||||
|
{
|
||||||
|
IdToken = request.IdentityToken,
|
||||||
|
Code = request.AuthorizationCode,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process the authentication
|
||||||
|
var userInfo = await appleService.ProcessCallbackAsync(callbackData);
|
||||||
|
|
||||||
|
// Find or create user account using existing logic
|
||||||
|
var account = await FindOrCreateAccount(userInfo, "apple");
|
||||||
|
|
||||||
|
// Create session using the OIDC service
|
||||||
|
var challenge = await appleService.CreateChallengeForUserAsync(
|
||||||
|
userInfo,
|
||||||
|
account,
|
||||||
|
HttpContext,
|
||||||
|
request.DeviceId
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(challenge);
|
||||||
|
}
|
||||||
|
catch (SecurityTokenValidationException ex)
|
||||||
|
{
|
||||||
|
return Unauthorized($"Invalid identity token: {ex.Message}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Log the error
|
||||||
|
return StatusCode(500, $"Authentication failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private OidcService GetOidcService(string provider)
|
||||||
|
{
|
||||||
|
return provider.ToLower() switch
|
||||||
|
{
|
||||||
|
"apple" => serviceProvider.GetRequiredService<AppleOidcService>(),
|
||||||
|
"google" => serviceProvider.GetRequiredService<GoogleOidcService>(),
|
||||||
|
"microsoft" => serviceProvider.GetRequiredService<MicrosoftOidcService>(),
|
||||||
|
"discord" => serviceProvider.GetRequiredService<DiscordOidcService>(),
|
||||||
|
"github" => serviceProvider.GetRequiredService<GitHubOidcService>(),
|
||||||
|
"afdian" => serviceProvider.GetRequiredService<AfdianOidcService>(),
|
||||||
|
_ => throw new ArgumentException($"Unsupported provider: {provider}")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Account.Account> FindOrCreateAccount(OidcUserInfo userInfo, string provider)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(userInfo.Email))
|
||||||
|
throw new ArgumentException("Email is required for account creation");
|
||||||
|
|
||||||
|
// Check if an account exists by email
|
||||||
|
var existingAccount = await accounts.LookupAccount(userInfo.Email);
|
||||||
|
if (existingAccount != null)
|
||||||
|
{
|
||||||
|
// Check if this provider connection already exists
|
||||||
|
var existingConnection = await db.AccountConnections
|
||||||
|
.FirstOrDefaultAsync(c => c.AccountId == existingAccount.Id &&
|
||||||
|
c.Provider == provider &&
|
||||||
|
c.ProvidedIdentifier == userInfo.UserId);
|
||||||
|
|
||||||
|
// If no connection exists, create one
|
||||||
|
if (existingConnection != null)
|
||||||
|
{
|
||||||
|
await db.AccountConnections
|
||||||
|
.Where(c => c.AccountId == existingAccount.Id &&
|
||||||
|
c.Provider == provider &&
|
||||||
|
c.ProvidedIdentifier == userInfo.UserId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(c => c.LastUsedAt, SystemClock.Instance.GetCurrentInstant())
|
||||||
|
.SetProperty(c => c.Meta, userInfo.ToMetadata()));
|
||||||
|
|
||||||
|
return existingAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
var connection = new AccountConnection
|
||||||
|
{
|
||||||
|
AccountId = existingAccount.Id,
|
||||||
|
Provider = provider,
|
||||||
|
ProvidedIdentifier = userInfo.UserId!,
|
||||||
|
AccessToken = userInfo.AccessToken,
|
||||||
|
RefreshToken = userInfo.RefreshToken,
|
||||||
|
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
|
||||||
|
Meta = userInfo.ToMetadata()
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.AccountConnections.AddAsync(connection);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return existingAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new account using the AccountService
|
||||||
|
var newAccount = await accounts.CreateAccount(userInfo);
|
||||||
|
|
||||||
|
// Create the provider connection
|
||||||
|
var newConnection = new AccountConnection
|
||||||
|
{
|
||||||
|
AccountId = newAccount.Id,
|
||||||
|
Provider = provider,
|
||||||
|
ProvidedIdentifier = userInfo.UserId!,
|
||||||
|
AccessToken = userInfo.AccessToken,
|
||||||
|
RefreshToken = userInfo.RefreshToken,
|
||||||
|
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
|
||||||
|
Meta = userInfo.ToMetadata()
|
||||||
|
};
|
||||||
|
|
||||||
|
db.AccountConnections.Add(newConnection);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return newAccount;
|
||||||
|
}
|
||||||
|
}
|
294
DysonNetwork.Pass/Auth/OpenId/OidcService.cs
Normal file
294
DysonNetwork.Pass/Auth/OpenId/OidcService.cs
Normal file
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base service for OpenID Connect authentication providers
|
||||||
|
/// </summary>
|
||||||
|
public abstract class OidcService(
|
||||||
|
IConfiguration configuration,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
AppDatabase db,
|
||||||
|
AuthService auth,
|
||||||
|
ICacheService cache
|
||||||
|
)
|
||||||
|
{
|
||||||
|
protected readonly IConfiguration Configuration = configuration;
|
||||||
|
protected readonly IHttpClientFactory HttpClientFactory = httpClientFactory;
|
||||||
|
protected readonly AppDatabase Db = db;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the unique identifier for this provider
|
||||||
|
/// </summary>
|
||||||
|
public abstract string ProviderName { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the OIDC discovery document endpoint
|
||||||
|
/// </summary>
|
||||||
|
protected abstract string DiscoveryEndpoint { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets configuration section name for this provider
|
||||||
|
/// </summary>
|
||||||
|
protected abstract string ConfigSectionName { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the authorization URL for initiating the authentication flow
|
||||||
|
/// </summary>
|
||||||
|
public abstract string GetAuthorizationUrl(string state, string nonce);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Process the callback from the OIDC provider
|
||||||
|
/// </summary>
|
||||||
|
public abstract Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the provider configuration
|
||||||
|
/// </summary>
|
||||||
|
protected ProviderConfiguration GetProviderConfig()
|
||||||
|
{
|
||||||
|
return new ProviderConfiguration
|
||||||
|
{
|
||||||
|
ClientId = Configuration[$"Oidc:{ConfigSectionName}:ClientId"] ?? "",
|
||||||
|
ClientSecret = Configuration[$"Oidc:{ConfigSectionName}:ClientSecret"] ?? "",
|
||||||
|
RedirectUri = Configuration["BaseUrl"] + "/auth/callback/" + ProviderName.ToLower()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves the OpenID Connect discovery document
|
||||||
|
/// </summary>
|
||||||
|
protected virtual async Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync()
|
||||||
|
{
|
||||||
|
// Construct a cache key unique to the current provider:
|
||||||
|
var cacheKey = $"oidc-discovery:{ProviderName}";
|
||||||
|
|
||||||
|
// Try getting the discovery document from cache first:
|
||||||
|
var (found, cachedDoc) = await cache.GetAsyncWithStatus<OidcDiscoveryDocument>(cacheKey);
|
||||||
|
if (found && cachedDoc != null)
|
||||||
|
{
|
||||||
|
return cachedDoc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's not cached, fetch from the actual discovery endpoint:
|
||||||
|
var client = HttpClientFactory.CreateClient();
|
||||||
|
var response = await client.GetAsync(DiscoveryEndpoint);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var doc = await response.Content.ReadFromJsonAsync<OidcDiscoveryDocument>();
|
||||||
|
|
||||||
|
// Store the discovery document in the cache for a while (e.g., 15 minutes):
|
||||||
|
if (doc is not null)
|
||||||
|
await cache.SetAsync(cacheKey, doc, TimeSpan.FromMinutes(15));
|
||||||
|
|
||||||
|
return doc;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exchange the authorization code for tokens
|
||||||
|
/// </summary>
|
||||||
|
protected virtual async Task<OidcTokenResponse?> ExchangeCodeForTokensAsync(string code,
|
||||||
|
string? codeVerifier = null)
|
||||||
|
{
|
||||||
|
var config = GetProviderConfig();
|
||||||
|
var discoveryDocument = await GetDiscoveryDocumentAsync();
|
||||||
|
|
||||||
|
if (discoveryDocument?.TokenEndpoint == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Token endpoint not found in discovery document");
|
||||||
|
}
|
||||||
|
|
||||||
|
var client = HttpClientFactory.CreateClient();
|
||||||
|
var content = new FormUrlEncodedContent(BuildTokenRequestParameters(code, config, codeVerifier));
|
||||||
|
|
||||||
|
var response = await client.PostAsync(discoveryDocument.TokenEndpoint, content);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the token request parameters
|
||||||
|
/// </summary>
|
||||||
|
protected virtual Dictionary<string, string> BuildTokenRequestParameters(string code, ProviderConfiguration config,
|
||||||
|
string? codeVerifier)
|
||||||
|
{
|
||||||
|
var parameters = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "client_id", config.ClientId },
|
||||||
|
{ "code", code },
|
||||||
|
{ "grant_type", "authorization_code" },
|
||||||
|
{ "redirect_uri", config.RedirectUri }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(config.ClientSecret))
|
||||||
|
{
|
||||||
|
parameters.Add("client_secret", config.ClientSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(codeVerifier))
|
||||||
|
{
|
||||||
|
parameters.Add("code_verifier", codeVerifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates and extracts information from an ID token
|
||||||
|
/// </summary>
|
||||||
|
protected virtual OidcUserInfo ValidateAndExtractIdToken(string idToken,
|
||||||
|
TokenValidationParameters validationParameters)
|
||||||
|
{
|
||||||
|
var handler = new JwtSecurityTokenHandler();
|
||||||
|
handler.ValidateToken(idToken, validationParameters, out _);
|
||||||
|
|
||||||
|
var jwtToken = handler.ReadJwtToken(idToken);
|
||||||
|
|
||||||
|
// Extract standard claims
|
||||||
|
var userId = jwtToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
|
||||||
|
var email = jwtToken.Claims.FirstOrDefault(c => c.Type == "email")?.Value;
|
||||||
|
var emailVerified = jwtToken.Claims.FirstOrDefault(c => c.Type == "email_verified")?.Value == "true";
|
||||||
|
var name = jwtToken.Claims.FirstOrDefault(c => c.Type == "name")?.Value;
|
||||||
|
var givenName = jwtToken.Claims.FirstOrDefault(c => c.Type == "given_name")?.Value;
|
||||||
|
var familyName = jwtToken.Claims.FirstOrDefault(c => c.Type == "family_name")?.Value;
|
||||||
|
var preferredUsername = jwtToken.Claims.FirstOrDefault(c => c.Type == "preferred_username")?.Value;
|
||||||
|
var picture = jwtToken.Claims.FirstOrDefault(c => c.Type == "picture")?.Value;
|
||||||
|
|
||||||
|
// Determine preferred username - try different options
|
||||||
|
var username = preferredUsername;
|
||||||
|
if (string.IsNullOrEmpty(username))
|
||||||
|
{
|
||||||
|
// Fall back to email local part if no preferred username
|
||||||
|
username = !string.IsNullOrEmpty(email) ? email.Split('@')[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OidcUserInfo
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
Email = email,
|
||||||
|
EmailVerified = emailVerified,
|
||||||
|
FirstName = givenName ?? "",
|
||||||
|
LastName = familyName ?? "",
|
||||||
|
DisplayName = name ?? $"{givenName} {familyName}".Trim(),
|
||||||
|
PreferredUsername = username ?? "",
|
||||||
|
ProfilePictureUrl = picture,
|
||||||
|
Provider = ProviderName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a challenge and session for an authenticated user
|
||||||
|
/// Also creates or updates the account connection
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Challenge> CreateChallengeForUserAsync(
|
||||||
|
OidcUserInfo userInfo,
|
||||||
|
Account.Account account,
|
||||||
|
HttpContext request,
|
||||||
|
string deviceId
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// Create or update the account connection
|
||||||
|
var connection = await Db.AccountConnections
|
||||||
|
.FirstOrDefaultAsync(c => c.Provider == ProviderName &&
|
||||||
|
c.ProvidedIdentifier == userInfo.UserId &&
|
||||||
|
c.AccountId == account.Id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (connection is null)
|
||||||
|
{
|
||||||
|
connection = new AccountConnection
|
||||||
|
{
|
||||||
|
Provider = ProviderName,
|
||||||
|
ProvidedIdentifier = userInfo.UserId ?? "",
|
||||||
|
AccessToken = userInfo.AccessToken,
|
||||||
|
RefreshToken = userInfo.RefreshToken,
|
||||||
|
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
|
||||||
|
AccountId = account.Id
|
||||||
|
};
|
||||||
|
await Db.AccountConnections.AddAsync(connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a challenge that's already completed
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var challenge = new Challenge
|
||||||
|
{
|
||||||
|
ExpiredAt = now.Plus(Duration.FromHours(1)),
|
||||||
|
StepTotal = await auth.DetectChallengeRisk(request.Request, account),
|
||||||
|
Type = ChallengeType.Oidc,
|
||||||
|
Platform = ChallengePlatform.Unidentified,
|
||||||
|
Audiences = [ProviderName],
|
||||||
|
Scopes = ["*"],
|
||||||
|
AccountId = account.Id,
|
||||||
|
DeviceId = deviceId,
|
||||||
|
IpAddress = request.Connection.RemoteIpAddress?.ToString() ?? null,
|
||||||
|
UserAgent = request.Request.Headers.UserAgent,
|
||||||
|
};
|
||||||
|
challenge.StepRemain--;
|
||||||
|
if (challenge.StepRemain < 0) challenge.StepRemain = 0;
|
||||||
|
|
||||||
|
await Db.AuthChallenges.AddAsync(challenge);
|
||||||
|
await Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return challenge;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provider configuration from app settings
|
||||||
|
/// </summary>
|
||||||
|
public class ProviderConfiguration
|
||||||
|
{
|
||||||
|
public string ClientId { get; set; } = "";
|
||||||
|
public string ClientSecret { get; set; } = "";
|
||||||
|
public string RedirectUri { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OIDC Discovery Document
|
||||||
|
/// </summary>
|
||||||
|
public class OidcDiscoveryDocument
|
||||||
|
{
|
||||||
|
[JsonPropertyName("authorization_endpoint")]
|
||||||
|
public string? AuthorizationEndpoint { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("token_endpoint")] public string? TokenEndpoint { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("userinfo_endpoint")]
|
||||||
|
public string? UserinfoEndpoint { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("jwks_uri")] public string? JwksUri { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response from the token endpoint
|
||||||
|
/// </summary>
|
||||||
|
public class OidcTokenResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("access_token")] public string? AccessToken { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("token_type")] public string? TokenType { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("expires_in")] public int ExpiresIn { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("refresh_token")] public string? RefreshToken { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("id_token")] public string? IdToken { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Data received in the callback from an OIDC provider
|
||||||
|
/// </summary>
|
||||||
|
public class OidcCallbackData
|
||||||
|
{
|
||||||
|
public string Code { get; set; } = "";
|
||||||
|
public string IdToken { get; set; } = "";
|
||||||
|
public string? State { get; set; }
|
||||||
|
public string? RawData { get; set; }
|
||||||
|
}
|
189
DysonNetwork.Pass/Auth/OpenId/OidcState.cs
Normal file
189
DysonNetwork.Pass/Auth/OpenId/OidcState.cs
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Auth.OpenId;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the state parameter used in OpenID Connect flows.
|
||||||
|
/// Handles serialization and deserialization of the state parameter.
|
||||||
|
/// </summary>
|
||||||
|
public class OidcState
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The type of OIDC flow (login or connect).
|
||||||
|
/// </summary>
|
||||||
|
public OidcFlowType FlowType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The account ID (for connect flow).
|
||||||
|
/// </summary>
|
||||||
|
public Guid? AccountId { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The OIDC provider name.
|
||||||
|
/// </summary>
|
||||||
|
public string? Provider { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The nonce for CSRF protection.
|
||||||
|
/// </summary>
|
||||||
|
public string? Nonce { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The device ID for the authentication request.
|
||||||
|
/// </summary>
|
||||||
|
public string? DeviceId { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The return URL after authentication (for login flow).
|
||||||
|
/// </summary>
|
||||||
|
public string? ReturnUrl { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new OidcState for a connection flow.
|
||||||
|
/// </summary>
|
||||||
|
public static OidcState ForConnection(Guid accountId, string provider, string nonce, string? deviceId = null)
|
||||||
|
{
|
||||||
|
return new OidcState
|
||||||
|
{
|
||||||
|
FlowType = OidcFlowType.Connect,
|
||||||
|
AccountId = accountId,
|
||||||
|
Provider = provider,
|
||||||
|
Nonce = nonce,
|
||||||
|
DeviceId = deviceId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new OidcState for a login flow.
|
||||||
|
/// </summary>
|
||||||
|
public static OidcState ForLogin(string returnUrl = "/", string? deviceId = null)
|
||||||
|
{
|
||||||
|
return new OidcState
|
||||||
|
{
|
||||||
|
FlowType = OidcFlowType.Login,
|
||||||
|
ReturnUrl = returnUrl,
|
||||||
|
DeviceId = deviceId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The version of the state format.
|
||||||
|
/// </summary>
|
||||||
|
public int Version { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes the state to a JSON string for use in OIDC flows.
|
||||||
|
/// </summary>
|
||||||
|
public string Serialize()
|
||||||
|
{
|
||||||
|
return JsonSerializer.Serialize(this, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to parse a state string into an OidcState object.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryParse(string? stateString, out OidcState? state)
|
||||||
|
{
|
||||||
|
state = null;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(stateString))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// First try to parse as JSON
|
||||||
|
try
|
||||||
|
{
|
||||||
|
state = JsonSerializer.Deserialize<OidcState>(stateString);
|
||||||
|
return state != null;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// Not a JSON string, try legacy format for backward compatibility
|
||||||
|
return TryParseLegacyFormat(stateString, out state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseLegacyFormat(string stateString, out OidcState? state)
|
||||||
|
{
|
||||||
|
state = null;
|
||||||
|
var parts = stateString.Split('|');
|
||||||
|
|
||||||
|
// Check for connection flow format: {accountId}|{provider}|{nonce}|{deviceId}|connect
|
||||||
|
if (parts.Length >= 5 &&
|
||||||
|
Guid.TryParse(parts[0], out var accountId) &&
|
||||||
|
string.Equals(parts[^1], "connect", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
state = new OidcState
|
||||||
|
{
|
||||||
|
FlowType = OidcFlowType.Connect,
|
||||||
|
AccountId = accountId,
|
||||||
|
Provider = parts[1],
|
||||||
|
Nonce = parts[2],
|
||||||
|
DeviceId = parts.Length >= 4 && !string.IsNullOrEmpty(parts[3]) ? parts[3] : null
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for login flow format: {returnUrl}|{deviceId}|login
|
||||||
|
if (parts.Length >= 2 &&
|
||||||
|
parts.Length <= 3 &&
|
||||||
|
(parts.Length < 3 || string.Equals(parts[^1], "login", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
state = new OidcState
|
||||||
|
{
|
||||||
|
FlowType = OidcFlowType.Login,
|
||||||
|
ReturnUrl = parts[0],
|
||||||
|
DeviceId = parts.Length >= 2 && !string.IsNullOrEmpty(parts[1]) ? parts[1] : null
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy format support (for backward compatibility)
|
||||||
|
if (parts.Length == 1)
|
||||||
|
{
|
||||||
|
state = new OidcState
|
||||||
|
{
|
||||||
|
FlowType = OidcFlowType.Login,
|
||||||
|
ReturnUrl = parts[0],
|
||||||
|
DeviceId = null
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the type of OIDC flow.
|
||||||
|
/// </summary>
|
||||||
|
public enum OidcFlowType
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Login or registration flow.
|
||||||
|
/// </summary>
|
||||||
|
Login,
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Account connection flow.
|
||||||
|
/// </summary>
|
||||||
|
Connect
|
||||||
|
}
|
49
DysonNetwork.Pass/Auth/OpenId/OidcUserInfo.cs
Normal file
49
DysonNetwork.Pass/Auth/OpenId/OidcUserInfo.cs
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
namespace DysonNetwork.Pass.Auth.OpenId;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the user information from an OIDC provider
|
||||||
|
/// </summary>
|
||||||
|
public class OidcUserInfo
|
||||||
|
{
|
||||||
|
public string? UserId { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public bool EmailVerified { get; set; }
|
||||||
|
public string FirstName { get; set; } = "";
|
||||||
|
public string LastName { get; set; } = "";
|
||||||
|
public string DisplayName { get; set; } = "";
|
||||||
|
public string PreferredUsername { get; set; } = "";
|
||||||
|
public string? ProfilePictureUrl { get; set; }
|
||||||
|
public string Provider { get; set; } = "";
|
||||||
|
public string? RefreshToken { get; set; }
|
||||||
|
public string? AccessToken { get; set; }
|
||||||
|
|
||||||
|
public Dictionary<string, object> ToMetadata()
|
||||||
|
{
|
||||||
|
var metadata = new Dictionary<string, object>();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(UserId))
|
||||||
|
metadata["user_id"] = UserId;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(Email))
|
||||||
|
metadata["email"] = Email;
|
||||||
|
|
||||||
|
metadata["email_verified"] = EmailVerified;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(FirstName))
|
||||||
|
metadata["first_name"] = FirstName;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(LastName))
|
||||||
|
metadata["last_name"] = LastName;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(DisplayName))
|
||||||
|
metadata["display_name"] = DisplayName;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(PreferredUsername))
|
||||||
|
metadata["preferred_username"] = PreferredUsername;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(ProfilePictureUrl))
|
||||||
|
metadata["profile_picture_url"] = ProfilePictureUrl;
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
}
|
69
DysonNetwork.Pass/Auth/Session.cs
Normal file
69
DysonNetwork.Pass/Auth/Session.cs
Normal file
@ -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<Guid> BlacklistFactors { get; set; } = new();
|
||||||
|
[Column(TypeName = "jsonb")] public List<string> Audiences { get; set; } = new();
|
||||||
|
[Column(TypeName = "jsonb")] public List<string> Scopes { get; set; } = new();
|
||||||
|
[MaxLength(128)] public string? IpAddress { get; set; }
|
||||||
|
[MaxLength(512)] public string? UserAgent { get; set; }
|
||||||
|
[MaxLength(256)] public string? DeviceId { get; set; }
|
||||||
|
[MaxLength(1024)] public string? Nonce { get; set; }
|
||||||
|
public Point? Location { get; set; }
|
||||||
|
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
||||||
|
|
||||||
|
public Challenge Normalize()
|
||||||
|
{
|
||||||
|
if (StepRemain == 0 && BlacklistFactors.Count == 0) StepRemain = StepTotal;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
23
DysonNetwork.Pass/Dockerfile
Normal file
23
DysonNetwork.Pass/Dockerfile
Normal file
@ -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"]
|
42
DysonNetwork.Pass/DysonNetwork.Pass.csproj
Normal file
42
DysonNetwork.Pass/DysonNetwork.Pass.csproj
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7"/>
|
||||||
|
<PackageReference Include="NodaTime" Version="3.2.2"/>
|
||||||
|
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0"/>
|
||||||
|
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/>
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/>
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0"/>
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="9.0.4"/>
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4"/>
|
||||||
|
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0"/>
|
||||||
|
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0"/>
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0"/>
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0"/>
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0"/>
|
||||||
|
<PackageReference Include="Otp.NET" Version="1.4.0"/>
|
||||||
|
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1"/>
|
||||||
|
<PackageReference Include="prometheus-net.AspNetCore.HealthChecks" Version="8.2.1"/>
|
||||||
|
<PackageReference Include="prometheus-net.DotNetRuntime" Version="4.4.1"/>
|
||||||
|
<PackageReference Include="prometheus-net.EntityFramework" Version="0.9.5"/>
|
||||||
|
<PackageReference Include="prometheus-net.SystemMetrics" Version="3.1.0"/>
|
||||||
|
<PackageReference Include="Quartz" Version="3.14.0"/>
|
||||||
|
<PackageReference Include="Quartz.AspNetCore" Version="3.14.0"/>
|
||||||
|
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0"/>
|
||||||
|
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3"/>
|
||||||
|
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.1"/>
|
||||||
|
<PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1"/>
|
||||||
|
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
6
DysonNetwork.Pass/DysonNetwork.Pass.http
Normal file
6
DysonNetwork.Pass/DysonNetwork.Pass.http
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
@DysonNetwork.Pass_HostAddress = http://localhost:5216
|
||||||
|
|
||||||
|
GET {{DysonNetwork.Pass_HostAddress}}/weatherforecast/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
25
DysonNetwork.Pass/Handlers/ActionLogFlushHandler.cs
Normal file
25
DysonNetwork.Pass/Handlers/ActionLogFlushHandler.cs
Normal file
@ -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<ActionLog>
|
||||||
|
{
|
||||||
|
public async Task FlushAsync(IReadOnlyList<ActionLog> items)
|
||||||
|
{
|
||||||
|
using var scope = serviceProvider.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
61
DysonNetwork.Pass/Handlers/LastActiveFlushHandler.cs
Normal file
61
DysonNetwork.Pass/Handlers/LastActiveFlushHandler.cs
Normal file
@ -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<LastActiveInfo>
|
||||||
|
{
|
||||||
|
public async Task FlushAsync(IReadOnlyList<LastActiveInfo> items)
|
||||||
|
{
|
||||||
|
using var scope = serviceProvider.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
59
DysonNetwork.Pass/Permission/Permission.cs
Normal file
59
DysonNetwork.Pass/Permission/Permission.cs
Normal file
@ -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<PermissionNode> Nodes { get; set; } = new List<PermissionNode>();
|
||||||
|
[JsonIgnore] public ICollection<PermissionGroupMember> Members { get; set; } = new List<PermissionGroupMember>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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; }
|
||||||
|
}
|
51
DysonNetwork.Pass/Permission/PermissionMiddleware.cs
Normal file
51
DysonNetwork.Pass/Permission/PermissionMiddleware.cs
Normal file
@ -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<RequiredPermissionAttribute>()
|
||||||
|
.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<bool>(actor, attr.Area, attr.Key);
|
||||||
|
|
||||||
|
if (!permNode)
|
||||||
|
{
|
||||||
|
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||||
|
await httpContext.Response.WriteAsync($"Permission {attr.Area}/{attr.Key} = {true} was required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await next(httpContext);
|
||||||
|
}
|
||||||
|
}
|
198
DysonNetwork.Pass/Permission/PermissionService.cs
Normal file
198
DysonNetwork.Pass/Permission/PermissionService.cs
Normal file
@ -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<bool> HasPermissionAsync(string actor, string area, string key)
|
||||||
|
{
|
||||||
|
var value = await GetPermissionAsync<bool>(actor, area, key);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<T?> GetPermissionAsync<T>(string actor, string area, string key)
|
||||||
|
{
|
||||||
|
var cacheKey = _GetPermissionCacheKey(actor, area, key);
|
||||||
|
|
||||||
|
var (hit, cachedValue) = await cache.GetAsyncWithStatus<T>(cacheKey);
|
||||||
|
if (hit)
|
||||||
|
return cachedValue;
|
||||||
|
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var groupsKey = _GetGroupsCacheKey(actor);
|
||||||
|
|
||||||
|
var groupsId = await cache.GetAsync<List<Guid>>(groupsKey);
|
||||||
|
if (groupsId == null)
|
||||||
|
{
|
||||||
|
groupsId = await db.PermissionGroupMembers
|
||||||
|
.Where(n => n.Actor == actor)
|
||||||
|
.Where(n => n.ExpiredAt == null || n.ExpiredAt > now)
|
||||||
|
.Where(n => n.AffectedAt == null || n.AffectedAt <= now)
|
||||||
|
.Select(e => e.GroupId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
await cache.SetWithGroupsAsync(groupsKey, groupsId,
|
||||||
|
[_GetPermissionGroupKey(actor)],
|
||||||
|
CacheExpiration);
|
||||||
|
}
|
||||||
|
|
||||||
|
var permission = await db.PermissionNodes
|
||||||
|
.Where(n => (n.GroupId == null && n.Actor == actor) ||
|
||||||
|
(n.GroupId != null && groupsId.Contains(n.GroupId.Value)))
|
||||||
|
.Where(n => n.Key == key && n.Area == area)
|
||||||
|
.Where(n => n.ExpiredAt == null || n.ExpiredAt > now)
|
||||||
|
.Where(n => n.AffectedAt == null || n.AffectedAt <= now)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
var result = permission is not null ? _DeserializePermissionValue<T>(permission.Value) : default;
|
||||||
|
|
||||||
|
await cache.SetWithGroupsAsync(cacheKey, result,
|
||||||
|
[_GetPermissionGroupKey(actor)],
|
||||||
|
CacheExpiration);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PermissionNode> AddPermissionNode<T>(
|
||||||
|
string actor,
|
||||||
|
string area,
|
||||||
|
string key,
|
||||||
|
T value,
|
||||||
|
Instant? expiredAt = null,
|
||||||
|
Instant? affectedAt = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (value is null) throw new ArgumentNullException(nameof(value));
|
||||||
|
|
||||||
|
var node = new PermissionNode
|
||||||
|
{
|
||||||
|
Actor = actor,
|
||||||
|
Key = key,
|
||||||
|
Area = area,
|
||||||
|
Value = _SerializePermissionValue(value),
|
||||||
|
ExpiredAt = expiredAt,
|
||||||
|
AffectedAt = affectedAt
|
||||||
|
};
|
||||||
|
|
||||||
|
db.PermissionNodes.Add(node);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Invalidate related caches
|
||||||
|
await InvalidatePermissionCacheAsync(actor, area, key);
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PermissionNode> AddPermissionNodeToGroup<T>(
|
||||||
|
PermissionGroup group,
|
||||||
|
string actor,
|
||||||
|
string area,
|
||||||
|
string key,
|
||||||
|
T value,
|
||||||
|
Instant? expiredAt = null,
|
||||||
|
Instant? affectedAt = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (value is null) throw new ArgumentNullException(nameof(value));
|
||||||
|
|
||||||
|
var node = new PermissionNode
|
||||||
|
{
|
||||||
|
Actor = actor,
|
||||||
|
Key = key,
|
||||||
|
Area = area,
|
||||||
|
Value = _SerializePermissionValue(value),
|
||||||
|
ExpiredAt = expiredAt,
|
||||||
|
AffectedAt = affectedAt,
|
||||||
|
Group = group,
|
||||||
|
GroupId = group.Id
|
||||||
|
};
|
||||||
|
|
||||||
|
db.PermissionNodes.Add(node);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Invalidate related caches
|
||||||
|
await InvalidatePermissionCacheAsync(actor, area, key);
|
||||||
|
await cache.RemoveAsync(_GetGroupsCacheKey(actor));
|
||||||
|
await cache.RemoveGroupAsync(_GetPermissionGroupKey(actor));
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemovePermissionNode(string actor, string area, string key)
|
||||||
|
{
|
||||||
|
var node = await db.PermissionNodes
|
||||||
|
.Where(n => n.Actor == actor && n.Area == area && n.Key == key)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (node is not null) db.PermissionNodes.Remove(node);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Invalidate cache
|
||||||
|
await InvalidatePermissionCacheAsync(actor, area, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemovePermissionNodeFromGroup<T>(PermissionGroup group, string actor, string area, string key)
|
||||||
|
{
|
||||||
|
var node = await db.PermissionNodes
|
||||||
|
.Where(n => n.GroupId == group.Id)
|
||||||
|
.Where(n => n.Actor == actor && n.Area == area && n.Key == key)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (node is null) return;
|
||||||
|
db.PermissionNodes.Remove(node);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Invalidate caches
|
||||||
|
await InvalidatePermissionCacheAsync(actor, area, key);
|
||||||
|
await cache.RemoveAsync(_GetGroupsCacheKey(actor));
|
||||||
|
await cache.RemoveGroupAsync(_GetPermissionGroupKey(actor));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InvalidatePermissionCacheAsync(string actor, string area, string key)
|
||||||
|
{
|
||||||
|
var cacheKey = _GetPermissionCacheKey(actor, area, key);
|
||||||
|
await cache.RemoveAsync(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T? _DeserializePermissionValue<T>(JsonDocument json)
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<T>(json.RootElement.GetRawText());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonDocument _SerializePermissionValue<T>(T obj)
|
||||||
|
{
|
||||||
|
var str = JsonSerializer.Serialize(obj);
|
||||||
|
return JsonDocument.Parse(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PermissionNode NewPermissionNode<T>(string actor, string area, string key, T value)
|
||||||
|
{
|
||||||
|
return new PermissionNode
|
||||||
|
{
|
||||||
|
Actor = actor,
|
||||||
|
Area = area,
|
||||||
|
Key = key,
|
||||||
|
Value = _SerializePermissionValue(value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
23
DysonNetwork.Pass/Program.cs
Normal file
23
DysonNetwork.Pass/Program.cs
Normal file
@ -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();
|
23
DysonNetwork.Pass/Properties/launchSettings.json
Normal file
23
DysonNetwork.Pass/Properties/launchSettings.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
12
DysonNetwork.Pass/WeatherForecast.cs
Normal file
12
DysonNetwork.Pass/WeatherForecast.cs
Normal file
@ -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; }
|
||||||
|
}
|
8
DysonNetwork.Pass/appsettings.Development.json
Normal file
8
DysonNetwork.Pass/appsettings.Development.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
9
DysonNetwork.Pass/appsettings.json
Normal file
9
DysonNetwork.Pass/appsettings.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
396
DysonNetwork.Shared/Cache/CacheService.cs
Normal file
396
DysonNetwork.Shared/Cache/CacheService.cs
Normal file
@ -0,0 +1,396 @@
|
|||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Serialization;
|
||||||
|
using NodaTime;
|
||||||
|
using NodaTime.Serialization.JsonNet;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Shared.Cache;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a distributed lock that can be used to synchronize access across multiple processes
|
||||||
|
/// </summary>
|
||||||
|
public interface IDistributedLock : IAsyncDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The resource identifier this lock is protecting
|
||||||
|
/// </summary>
|
||||||
|
string Resource { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unique identifier for this lock instance
|
||||||
|
/// </summary>
|
||||||
|
string LockId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extends the lock's expiration time
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> ExtendAsync(TimeSpan timeSpan);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Releases the lock immediately
|
||||||
|
/// </summary>
|
||||||
|
Task ReleaseAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ICacheService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sets a value in the cache with an optional expiration time
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value from the cache
|
||||||
|
/// </summary>
|
||||||
|
Task<T?> GetAsync<T>(string key);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get a value from the cache with the found status
|
||||||
|
/// </summary>
|
||||||
|
Task<(bool found, T? value)> GetAsyncWithStatus<T>(string key);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes a specific key from the cache
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> RemoveAsync(string key);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a key to a group for group-based operations
|
||||||
|
/// </summary>
|
||||||
|
Task AddToGroupAsync(string key, string group);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes all keys associated with a specific group
|
||||||
|
/// </summary>
|
||||||
|
Task RemoveGroupAsync(string group);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all keys belonging to a specific group
|
||||||
|
/// </summary>
|
||||||
|
Task<IEnumerable<string>> GetGroupKeysAsync(string group);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper method to set a value in cache and associate it with multiple groups in one operation
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The type of value being cached</typeparam>
|
||||||
|
/// <param name="key">Cache key</param>
|
||||||
|
/// <param name="value">The value to cache</param>
|
||||||
|
/// <param name="groups">Optional collection of group names to associate the key with</param>
|
||||||
|
/// <param name="expiry">Optional expiration time for the cached item</param>
|
||||||
|
/// <returns>True if the set operation was successful</returns>
|
||||||
|
Task<bool> SetWithGroupsAsync<T>(string key, T value, IEnumerable<string>? groups = null, TimeSpan? expiry = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Acquires a distributed lock on the specified resource
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="resource">The resource identifier to lock</param>
|
||||||
|
/// <param name="expiry">How long the lock should be held before automatically expiring</param>
|
||||||
|
/// <param name="waitTime">How long to wait for the lock before giving up</param>
|
||||||
|
/// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
|
||||||
|
/// <returns>A distributed lock instance if acquired, null otherwise</returns>
|
||||||
|
Task<IDistributedLock?> AcquireLockAsync(string resource, TimeSpan expiry, TimeSpan? waitTime = null,
|
||||||
|
TimeSpan? retryInterval = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes an action with a distributed lock, ensuring the lock is properly released afterwards
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="resource">The resource identifier to lock</param>
|
||||||
|
/// <param name="action">The action to execute while holding the lock</param>
|
||||||
|
/// <param name="expiry">How long the lock should be held before automatically expiring</param>
|
||||||
|
/// <param name="waitTime">How long to wait for the lock before giving up</param>
|
||||||
|
/// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
|
||||||
|
/// <returns>True if the lock was acquired and the action was executed, false otherwise</returns>
|
||||||
|
Task<bool> ExecuteWithLockAsync(string resource, Func<Task> action, TimeSpan expiry, TimeSpan? waitTime = null,
|
||||||
|
TimeSpan? retryInterval = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes a function with a distributed lock, ensuring the lock is properly released afterwards
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The return type of the function</typeparam>
|
||||||
|
/// <param name="resource">The resource identifier to lock</param>
|
||||||
|
/// <param name="func">The function to execute while holding the lock</param>
|
||||||
|
/// <param name="expiry">How long the lock should be held before automatically expiring</param>
|
||||||
|
/// <param name="waitTime">How long to wait for the lock before giving up</param>
|
||||||
|
/// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
|
||||||
|
/// <returns>The result of the function if the lock was acquired, default(T) otherwise</returns>
|
||||||
|
Task<(bool Acquired, T? Result)> ExecuteWithLockAsync<T>(string resource, Func<Task<T>> func, TimeSpan expiry,
|
||||||
|
TimeSpan? waitTime = null, TimeSpan? retryInterval = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RedisDistributedLock : IDistributedLock
|
||||||
|
{
|
||||||
|
private readonly IDatabase _database;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public string Resource { get; }
|
||||||
|
public string LockId { get; }
|
||||||
|
|
||||||
|
internal RedisDistributedLock(IDatabase database, string resource, string lockId)
|
||||||
|
{
|
||||||
|
_database = database;
|
||||||
|
Resource = resource;
|
||||||
|
LockId = lockId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ExtendAsync(TimeSpan timeSpan)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
throw new ObjectDisposedException(nameof(RedisDistributedLock));
|
||||||
|
|
||||||
|
var script = @"
|
||||||
|
if redis.call('get', KEYS[1]) == ARGV[1] then
|
||||||
|
return redis.call('pexpire', KEYS[1], ARGV[2])
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
";
|
||||||
|
|
||||||
|
var result = await _database.ScriptEvaluateAsync(
|
||||||
|
script,
|
||||||
|
[$"{CacheServiceRedis.LockKeyPrefix}{Resource}"],
|
||||||
|
[LockId, (long)timeSpan.TotalMilliseconds]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (long)result! == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReleaseAsync()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var script = @"
|
||||||
|
if redis.call('get', KEYS[1]) == ARGV[1] then
|
||||||
|
return redis.call('del', KEYS[1])
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
";
|
||||||
|
|
||||||
|
await _database.ScriptEvaluateAsync(
|
||||||
|
script,
|
||||||
|
[$"{CacheServiceRedis.LockKeyPrefix}{Resource}"],
|
||||||
|
[LockId]
|
||||||
|
);
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await ReleaseAsync();
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CacheServiceRedis : ICacheService
|
||||||
|
{
|
||||||
|
private readonly IDatabase _database;
|
||||||
|
private readonly JsonSerializerSettings _serializerSettings;
|
||||||
|
|
||||||
|
// Global prefix for all cache keys
|
||||||
|
public const string GlobalKeyPrefix = "dyson:";
|
||||||
|
|
||||||
|
// Using prefixes for different types of keys
|
||||||
|
public const string GroupKeyPrefix = GlobalKeyPrefix + "cg:";
|
||||||
|
public const string LockKeyPrefix = GlobalKeyPrefix + "lock:";
|
||||||
|
|
||||||
|
public CacheServiceRedis(IConnectionMultiplexer redis)
|
||||||
|
{
|
||||||
|
var rds = redis ?? throw new ArgumentNullException(nameof(redis));
|
||||||
|
_database = rds.GetDatabase();
|
||||||
|
|
||||||
|
// Configure Newtonsoft.Json with proper NodaTime serialization
|
||||||
|
_serializerSettings = new JsonSerializerSettings
|
||||||
|
{
|
||||||
|
ContractResolver = new CamelCasePropertyNamesContractResolver(),
|
||||||
|
PreserveReferencesHandling = PreserveReferencesHandling.Objects,
|
||||||
|
NullValueHandling = NullValueHandling.Include,
|
||||||
|
DateParseHandling = DateParseHandling.None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Configure NodaTime serializers
|
||||||
|
_serializerSettings.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null)
|
||||||
|
{
|
||||||
|
key = $"{GlobalKeyPrefix}{key}";
|
||||||
|
if (string.IsNullOrEmpty(key))
|
||||||
|
throw new ArgumentException("Key cannot be null or empty", nameof(key));
|
||||||
|
|
||||||
|
var serializedValue = JsonConvert.SerializeObject(value, _serializerSettings);
|
||||||
|
return await _database.StringSetAsync(key, serializedValue, expiry);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<T?> GetAsync<T>(string key)
|
||||||
|
{
|
||||||
|
key = $"{GlobalKeyPrefix}{key}";
|
||||||
|
if (string.IsNullOrEmpty(key))
|
||||||
|
throw new ArgumentException("Key cannot be null or empty", nameof(key));
|
||||||
|
|
||||||
|
var value = await _database.StringGetAsync(key);
|
||||||
|
|
||||||
|
if (value.IsNullOrEmpty)
|
||||||
|
return default;
|
||||||
|
|
||||||
|
// For NodaTime serialization, use the configured serializer settings
|
||||||
|
return JsonConvert.DeserializeObject<T>(value!, _serializerSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(bool found, T? value)> GetAsyncWithStatus<T>(string key)
|
||||||
|
{
|
||||||
|
key = $"{GlobalKeyPrefix}{key}";
|
||||||
|
if (string.IsNullOrEmpty(key))
|
||||||
|
throw new ArgumentException("Key cannot be null or empty", nameof(key));
|
||||||
|
|
||||||
|
var value = await _database.StringGetAsync(key);
|
||||||
|
|
||||||
|
if (value.IsNullOrEmpty)
|
||||||
|
return (false, default);
|
||||||
|
|
||||||
|
// For NodaTime serialization, use the configured serializer settings
|
||||||
|
return (true, JsonConvert.DeserializeObject<T>(value!, _serializerSettings));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RemoveAsync(string key)
|
||||||
|
{
|
||||||
|
key = $"{GlobalKeyPrefix}{key}";
|
||||||
|
if (string.IsNullOrEmpty(key))
|
||||||
|
throw new ArgumentException("Key cannot be null or empty", nameof(key));
|
||||||
|
|
||||||
|
// Before removing the key, find all groups it belongs to and remove it from them
|
||||||
|
var script = @"
|
||||||
|
local groups = redis.call('KEYS', ARGV[1])
|
||||||
|
for _, group in ipairs(groups) do
|
||||||
|
redis.call('SREM', group, ARGV[2])
|
||||||
|
end
|
||||||
|
return redis.call('DEL', ARGV[2])
|
||||||
|
";
|
||||||
|
|
||||||
|
var result = await _database.ScriptEvaluateAsync(
|
||||||
|
script,
|
||||||
|
values: [$"{GroupKeyPrefix}*", key]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (long)result! > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddToGroupAsync(string key, string group)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(key))
|
||||||
|
throw new ArgumentException(@"Key cannot be null or empty.", nameof(key));
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(group))
|
||||||
|
throw new ArgumentException(@"Group cannot be null or empty.", nameof(group));
|
||||||
|
|
||||||
|
var groupKey = $"{GroupKeyPrefix}{group}";
|
||||||
|
key = $"{GlobalKeyPrefix}{key}";
|
||||||
|
await _database.SetAddAsync(groupKey, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemoveGroupAsync(string group)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(group))
|
||||||
|
throw new ArgumentException(@"Group cannot be null or empty.", nameof(group));
|
||||||
|
|
||||||
|
var groupKey = $"{GroupKeyPrefix}{group}";
|
||||||
|
|
||||||
|
// Get all keys in the group
|
||||||
|
var keys = await _database.SetMembersAsync(groupKey);
|
||||||
|
|
||||||
|
if (keys.Length > 0)
|
||||||
|
{
|
||||||
|
// Delete all the keys
|
||||||
|
var keysTasks = keys.Select(key => _database.KeyDeleteAsync(key.ToString()));
|
||||||
|
await Task.WhenAll(keysTasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the group itself
|
||||||
|
await _database.KeyDeleteAsync(groupKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<string>> GetGroupKeysAsync(string group)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(group))
|
||||||
|
throw new ArgumentException(@"Group cannot be null or empty.", nameof(group));
|
||||||
|
|
||||||
|
var groupKey = $"{GroupKeyPrefix}{group}";
|
||||||
|
var members = await _database.SetMembersAsync(groupKey);
|
||||||
|
|
||||||
|
return members.Select(m => m.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> SetWithGroupsAsync<T>(string key, T value, IEnumerable<string>? groups = null,
|
||||||
|
TimeSpan? expiry = null)
|
||||||
|
{
|
||||||
|
// First, set the value in the cache
|
||||||
|
var setResult = await SetAsync(key, value, expiry);
|
||||||
|
|
||||||
|
// If successful and there are groups to associate, add the key to each group
|
||||||
|
if (!setResult || groups == null) return setResult;
|
||||||
|
var groupsArray = groups.Where(g => !string.IsNullOrEmpty(g)).ToArray();
|
||||||
|
if (groupsArray.Length <= 0) return setResult;
|
||||||
|
var tasks = groupsArray.Select(group => AddToGroupAsync(key, group));
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
return setResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IDistributedLock?> AcquireLockAsync(string resource, TimeSpan expiry, TimeSpan? waitTime = null,
|
||||||
|
TimeSpan? retryInterval = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(resource))
|
||||||
|
throw new ArgumentException("Resource cannot be null or empty", nameof(resource));
|
||||||
|
|
||||||
|
var lockKey = $"{LockKeyPrefix}{resource}";
|
||||||
|
var lockId = Guid.NewGuid().ToString("N");
|
||||||
|
var waitTimeSpan = waitTime ?? TimeSpan.Zero;
|
||||||
|
var retryIntervalSpan = retryInterval ?? TimeSpan.FromMilliseconds(100);
|
||||||
|
|
||||||
|
var startTime = DateTime.UtcNow;
|
||||||
|
var acquired = false;
|
||||||
|
|
||||||
|
// Try to acquire the lock, retry until waitTime is exceeded
|
||||||
|
while (!acquired && (DateTime.UtcNow - startTime) < waitTimeSpan)
|
||||||
|
{
|
||||||
|
acquired = await _database.StringSetAsync(lockKey, lockId, expiry, When.NotExists);
|
||||||
|
|
||||||
|
if (!acquired)
|
||||||
|
{
|
||||||
|
await Task.Delay(retryIntervalSpan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!acquired)
|
||||||
|
{
|
||||||
|
return null; // Could not acquire the lock within the wait time
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RedisDistributedLock(_database, resource, lockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ExecuteWithLockAsync(string resource, Func<Task> action, TimeSpan expiry,
|
||||||
|
TimeSpan? waitTime = null, TimeSpan? retryInterval = null)
|
||||||
|
{
|
||||||
|
await using var lockObj = await AcquireLockAsync(resource, expiry, waitTime, retryInterval);
|
||||||
|
|
||||||
|
if (lockObj == null)
|
||||||
|
return false; // Could not acquire the lock
|
||||||
|
|
||||||
|
await action();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(bool Acquired, T? Result)> ExecuteWithLockAsync<T>(string resource, Func<Task<T>> func,
|
||||||
|
TimeSpan expiry, TimeSpan? waitTime = null, TimeSpan? retryInterval = null)
|
||||||
|
{
|
||||||
|
await using var lockObj = await AcquireLockAsync(resource, expiry, waitTime, retryInterval);
|
||||||
|
|
||||||
|
if (lockObj == null)
|
||||||
|
return (false, default); // Could not acquire the lock
|
||||||
|
|
||||||
|
var result = await func();
|
||||||
|
return (true, result);
|
||||||
|
}
|
||||||
|
}
|
66
DysonNetwork.Shared/Cache/FlushBufferService.cs
Normal file
66
DysonNetwork.Shared/Cache/FlushBufferService.cs
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Shared.Cache;
|
||||||
|
|
||||||
|
public interface IFlushHandler<T>
|
||||||
|
{
|
||||||
|
Task FlushAsync(IReadOnlyList<T> items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FlushBufferService
|
||||||
|
{
|
||||||
|
private readonly Dictionary<Type, object> _buffers = new();
|
||||||
|
private readonly Lock _lockObject = new();
|
||||||
|
|
||||||
|
private ConcurrentQueue<T> _GetOrCreateBuffer<T>()
|
||||||
|
{
|
||||||
|
var type = typeof(T);
|
||||||
|
lock (_lockObject)
|
||||||
|
{
|
||||||
|
if (!_buffers.TryGetValue(type, out var buffer))
|
||||||
|
{
|
||||||
|
buffer = new ConcurrentQueue<T>();
|
||||||
|
_buffers[type] = buffer;
|
||||||
|
}
|
||||||
|
return (ConcurrentQueue<T>)buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Enqueue<T>(T item)
|
||||||
|
{
|
||||||
|
var buffer = _GetOrCreateBuffer<T>();
|
||||||
|
buffer.Enqueue(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task FlushAsync<T>(IFlushHandler<T> handler)
|
||||||
|
{
|
||||||
|
var buffer = _GetOrCreateBuffer<T>();
|
||||||
|
var workingQueue = new List<T>();
|
||||||
|
|
||||||
|
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<T>()
|
||||||
|
{
|
||||||
|
var buffer = _GetOrCreateBuffer<T>();
|
||||||
|
return buffer.Count;
|
||||||
|
}
|
||||||
|
}
|
18
DysonNetwork.Shared/DysonNetwork.Shared.csproj
Normal file
18
DysonNetwork.Shared/DysonNetwork.Shared.csproj
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7" />
|
||||||
|
<PackageReference Include="NetTopologySuite" Version="2.6.0" />
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
|
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
|
||||||
|
<PackageReference Include="StackExchange.Redis" Version="2.8.41" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
56
DysonNetwork.Shared/Geo/GeoIpService.cs
Normal file
56
DysonNetwork.Shared/Geo/GeoIpService.cs
Normal file
@ -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<GeoIpOptions> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -34,7 +34,7 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7" />
|
||||||
<PackageReference Include="MimeTypes" Version="2.5.2">
|
<PackageReference Include="MimeTypes" Version="2.5.2">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
@ -69,7 +69,7 @@
|
|||||||
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.9" />
|
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.9" />
|
||||||
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.9" />
|
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.9" />
|
||||||
<PackageReference Include="SkiaSharp.NativeAssets.macOS" Version="2.88.9" />
|
<PackageReference Include="SkiaSharp.NativeAssets.macOS" Version="2.88.9" />
|
||||||
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
|
<PackageReference Include="StackExchange.Redis" Version="2.8.41" />
|
||||||
<PackageReference Include="StackExchange.Redis.Extensions.AspNetCore" Version="11.0.0" />
|
<PackageReference Include="StackExchange.Redis.Extensions.AspNetCore" Version="11.0.0" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.0" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.0" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="8.1.0" />
|
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="8.1.0" />
|
||||||
|
@ -26,11 +26,11 @@ public class FileController(
|
|||||||
{
|
{
|
||||||
// Support the file extension for client side data recognize
|
// Support the file extension for client side data recognize
|
||||||
string? fileExtension = null;
|
string? fileExtension = null;
|
||||||
if (id.Contains("."))
|
if (id.Contains('.'))
|
||||||
{
|
{
|
||||||
var splitedId = id.Split('.');
|
var splitId = id.Split('.');
|
||||||
id = splitedId.First();
|
id = splitId.First();
|
||||||
fileExtension = splitedId.Last();
|
fileExtension = splitId.Last();
|
||||||
}
|
}
|
||||||
|
|
||||||
var file = await fs.GetFileAsync(id);
|
var file = await fs.GetFileAsync(id);
|
||||||
|
@ -7,6 +7,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
|
|||||||
compose.yaml = compose.yaml
|
compose.yaml = compose.yaml
|
||||||
EndProjectSection
|
EndProjectSection
|
||||||
EndProject
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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}.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.ActiveCfg = Release|Any CPU
|
||||||
{CFF62EFA-F4C2-4FC7-8D97-25570B4DB452}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
@ -146,4 +146,4 @@
|
|||||||
<s:String x:Key="/Default/ResxEditorPersonal/DisabledLanguages/@EntryValue"></s:String>
|
<s:String x:Key="/Default/ResxEditorPersonal/DisabledLanguages/@EntryValue"></s:String>
|
||||||
<s:Boolean x:Key="/Default/ResxEditorPersonal/Initialized/@EntryValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/ResxEditorPersonal/Initialized/@EntryValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/ResxEditorPersonal/ShowComments/@EntryValue">False</s:Boolean>
|
<s:Boolean x:Key="/Default/ResxEditorPersonal/ShowComments/@EntryValue">False</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/ResxEditorPersonal/ShowOnlyErrors/@EntryValue">True</s:Boolean></wpf:ResourceDictionary>
|
<s:Boolean x:Key="/Default/ResxEditorPersonal/ShowOnlyErrors/@EntryValue">False</s:Boolean></wpf:ResourceDictionary>
|
Reference in New Issue
Block a user