Compare commits
4 Commits
2a3918134f
...
33f56c4ef5
| Author | SHA1 | Date | |
|---|---|---|---|
| 33f56c4ef5 | |||
| 0318364bcf | |||
| ba49d1c7a7 | |||
| e76c80eead |
31
DysonNetwork.Pass/Account/AbuseReport.cs
Normal file
31
DysonNetwork.Pass/Account/AbuseReport.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Shared.Data;
|
||||
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!;
|
||||
}
|
||||
191
DysonNetwork.Pass/Account/Account.cs
Normal file
191
DysonNetwork.Pass/Account/Account.cs
Normal file
@@ -0,0 +1,191 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Shared.Data;
|
||||
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 AccountProfile Profile { get; set; } = null!;
|
||||
public ICollection<AccountContact> Contacts { get; set; } = new List<AccountContact>();
|
||||
public ICollection<AccountBadge> Badges { get; set; } = new List<AccountBadge>();
|
||||
|
||||
[JsonIgnore] public ICollection<AccountAuthFactor> AuthFactors { get; set; } = new List<AccountAuthFactor>();
|
||||
[JsonIgnore] public ICollection<AccountConnection> Connections { get; set; } = new List<AccountConnection>();
|
||||
[JsonIgnore] public ICollection<Auth.AuthSession> Sessions { get; set; } = new List<Auth.AuthSession>();
|
||||
[JsonIgnore] public ICollection<Auth.AuthChallenge> Challenges { get; set; } = new List<Auth.AuthChallenge>();
|
||||
|
||||
[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 AccountProfile : 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<AccountBadge>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<List<AccountBadge>>> 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();
|
||||
}
|
||||
}
|
||||
654
DysonNetwork.Pass/Account/AccountCurrentController.cs
Normal file
654
DysonNetwork.Pass/Account/AccountCurrentController.cs
Normal file
@@ -0,0 +1,654 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Pass.Auth;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("/api/accounts/me")]
|
||||
public class AccountCurrentController(
|
||||
AppDatabase db,
|
||||
AccountService accounts,
|
||||
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<AccountProfile>> 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)
|
||||
{
|
||||
// TODO: Create reference, set profile picture
|
||||
}
|
||||
|
||||
if (request.BackgroundId is not null)
|
||||
{
|
||||
// TODO: Create reference, set profile background
|
||||
}
|
||||
|
||||
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<AuthSession> 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 AuthSession 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<AuthSession>>> GetSessions(
|
||||
[FromQuery] int take = 20,
|
||||
[FromQuery] int offset = 0
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
||||
HttpContext.Items["CurrentSession"] is not AuthSession 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<AuthSession>> 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<AuthSession>> DeleteCurrentSession()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
||||
HttpContext.Items["CurrentSession"] is not AuthSession 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<AuthSession>> 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<AuthSession>> UpdateCurrentSessionLabel([FromBody] string label)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
||||
HttpContext.Items["CurrentSession"] is not AuthSession 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<AccountBadge>>(StatusCodes.Status200OK)]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<AccountBadge>>> 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<AccountBadge>> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
335
DysonNetwork.Pass/Account/AccountEventService.cs
Normal file
335
DysonNetwork.Pass/Account/AccountEventService.cs
Normal file
@@ -0,0 +1,335 @@
|
||||
using System.Globalization;
|
||||
using DysonNetwork.Pass.Wallet;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
public class AccountEventService(
|
||||
AppDatabase db,
|
||||
PaymentService payment,
|
||||
ICacheService cache,
|
||||
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 = false; // TODO: Get connection status
|
||||
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 = false; // 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 = false; // 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();
|
||||
}
|
||||
}
|
||||
655
DysonNetwork.Pass/Account/AccountService.cs
Normal file
655
DysonNetwork.Pass/Account/AccountService.cs
Normal file
@@ -0,0 +1,655 @@
|
||||
using System.Globalization;
|
||||
using DysonNetwork.Pass.Auth;
|
||||
using DysonNetwork.Pass.Auth.OpenId;
|
||||
using DysonNetwork.Pass.Email;
|
||||
using DysonNetwork.Pass.Localization;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using EFCore.BulkExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using NodaTime;
|
||||
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 AccountProfile()
|
||||
};
|
||||
|
||||
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<AuthSession> 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<AccountBadge> GrantBadge(Account account, AccountBadge 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 AccountProfile { Id = Guid.NewGuid(), AccountId = id }).ToList();
|
||||
await db.BulkInsertAsync(newProfiles);
|
||||
}
|
||||
}
|
||||
}
|
||||
303
DysonNetwork.Pass/Account/AccountServiceGrpc.cs
Normal file
303
DysonNetwork.Pass/Account/AccountServiceGrpc.cs
Normal file
@@ -0,0 +1,303 @@
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.Protobuf;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
public class AccountServiceGrpc(
|
||||
AppDatabase db,
|
||||
IClock clock,
|
||||
ILogger<AccountServiceGrpc> logger
|
||||
)
|
||||
: Shared.Proto.AccountService.AccountServiceBase
|
||||
{
|
||||
private readonly AppDatabase _db = db ?? throw new ArgumentNullException(nameof(db));
|
||||
private readonly IClock _clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
|
||||
private readonly ILogger<AccountServiceGrpc>
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
// Helper methods for conversion between protobuf and domain models
|
||||
private static Shared.Proto.Account ToProtoAccount(Account account) => new()
|
||||
{
|
||||
Id = account.Id.ToString(),
|
||||
Name = account.Name,
|
||||
Nick = account.Nick,
|
||||
Language = account.Language,
|
||||
ActivatedAt = account.ActivatedAt?.ToTimestamp(),
|
||||
IsSuperuser = account.IsSuperuser,
|
||||
Profile = ToProtoProfile(account.Profile)
|
||||
// Note: Collections are not included by default to avoid large payloads
|
||||
// They should be loaded on demand via specific methods
|
||||
};
|
||||
|
||||
private static Shared.Proto.AccountProfile ToProtoProfile(AccountProfile profile) => new()
|
||||
{
|
||||
Id = profile.Id.ToString(),
|
||||
FirstName = profile.FirstName,
|
||||
MiddleName = profile.MiddleName,
|
||||
LastName = profile.LastName,
|
||||
Bio = profile.Bio,
|
||||
Gender = profile.Gender,
|
||||
Pronouns = profile.Pronouns,
|
||||
TimeZone = profile.TimeZone,
|
||||
Location = profile.Location,
|
||||
Birthday = profile.Birthday?.ToTimestamp(),
|
||||
LastSeenAt = profile.LastSeenAt?.ToTimestamp(),
|
||||
Experience = profile.Experience,
|
||||
Level = profile.Level,
|
||||
LevelingProgress = profile.LevelingProgress,
|
||||
AccountId = profile.AccountId.ToString(),
|
||||
PictureId = profile.PictureId,
|
||||
BackgroundId = profile.BackgroundId,
|
||||
Picture = profile.Picture?.ToProtoValue(),
|
||||
Background = profile.Background?.ToProtoValue()
|
||||
};
|
||||
|
||||
private static Shared.Proto.AccountContact ToProtoContact(AccountContact contact) => new()
|
||||
{
|
||||
Id = contact.Id.ToString(),
|
||||
Type = contact.Type switch
|
||||
{
|
||||
AccountContactType.Address => Shared.Proto.AccountContactType.Address,
|
||||
AccountContactType.PhoneNumber => Shared.Proto.AccountContactType.PhoneNumber,
|
||||
AccountContactType.Email => Shared.Proto.AccountContactType.Email,
|
||||
_ => Shared.Proto.AccountContactType.Unspecified
|
||||
},
|
||||
VerifiedAt = contact.VerifiedAt?.ToTimestamp(),
|
||||
IsPrimary = contact.IsPrimary,
|
||||
Content = contact.Content,
|
||||
AccountId = contact.AccountId.ToString()
|
||||
};
|
||||
|
||||
private static Shared.Proto.AccountBadge ToProtoBadge(AccountBadge badge) => new()
|
||||
{
|
||||
Id = badge.Id.ToString(),
|
||||
Type = badge.Type,
|
||||
Label = badge.Label,
|
||||
Caption = badge.Caption,
|
||||
ActivatedAt = badge.ActivatedAt?.ToTimestamp(),
|
||||
ExpiredAt = badge.ExpiredAt?.ToTimestamp(),
|
||||
AccountId = badge.AccountId.ToString()
|
||||
};
|
||||
|
||||
// Implementation of gRPC service methods
|
||||
public override async Task<Shared.Proto.Account> GetAccount(GetAccountRequest request, ServerCallContext context)
|
||||
{
|
||||
if (!Guid.TryParse(request.Id, out var accountId))
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format"));
|
||||
|
||||
var account = await _db.Accounts
|
||||
.AsNoTracking()
|
||||
.Include(a => a.Profile)
|
||||
.FirstOrDefaultAsync(a => a.Id == accountId);
|
||||
|
||||
if (account == null)
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Account {request.Id} not found"));
|
||||
|
||||
return ToProtoAccount(account);
|
||||
}
|
||||
|
||||
public override async Task<Shared.Proto.Account> CreateAccount(CreateAccountRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
// Map protobuf request to domain model
|
||||
var account = new Account
|
||||
{
|
||||
Name = request.Name,
|
||||
Nick = request.Nick,
|
||||
Language = request.Language,
|
||||
IsSuperuser = request.IsSuperuser,
|
||||
ActivatedAt = request.Profile != null ? null : _clock.GetCurrentInstant(),
|
||||
Profile = new AccountProfile
|
||||
{
|
||||
FirstName = request.Profile?.FirstName,
|
||||
LastName = request.Profile?.LastName,
|
||||
// Initialize other profile fields as needed
|
||||
}
|
||||
};
|
||||
|
||||
// Add to database
|
||||
_db.Accounts.Add(account);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Created new account with ID {AccountId}", account.Id);
|
||||
return ToProtoAccount(account);
|
||||
}
|
||||
|
||||
public override async Task<Shared.Proto.Account> UpdateAccount(UpdateAccountRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
if (!Guid.TryParse(request.Id, out var accountId))
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format"));
|
||||
|
||||
var account = await _db.Accounts.FindAsync(accountId);
|
||||
if (account == null)
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Account {request.Id} not found"));
|
||||
|
||||
// Update fields if they are provided in the request
|
||||
if (request.Name != null) account.Name = request.Name;
|
||||
if (request.Nick != null) account.Nick = request.Nick;
|
||||
if (request.Language != null) account.Language = request.Language;
|
||||
if (request.IsSuperuser != null) account.IsSuperuser = request.IsSuperuser.Value;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return ToProtoAccount(account);
|
||||
}
|
||||
|
||||
public override async Task<Empty> DeleteAccount(DeleteAccountRequest request, ServerCallContext context)
|
||||
{
|
||||
if (!Guid.TryParse(request.Id, out var accountId))
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format"));
|
||||
|
||||
var account = await _db.Accounts.FindAsync(accountId);
|
||||
if (account == null)
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Account {request.Id} not found"));
|
||||
|
||||
_db.Accounts.Remove(account);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return new Empty();
|
||||
}
|
||||
|
||||
public override async Task<ListAccountsResponse> ListAccounts(ListAccountsRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
var query = _db.Accounts.AsNoTracking();
|
||||
|
||||
// Apply filters if provided
|
||||
if (!string.IsNullOrEmpty(request.Filter))
|
||||
{
|
||||
// Implement filtering logic based on request.Filter
|
||||
// This is a simplified example
|
||||
query = query.Where(a => a.Name.Contains(request.Filter) || a.Nick.Contains(request.Filter));
|
||||
}
|
||||
|
||||
// Apply ordering
|
||||
query = request.OrderBy switch
|
||||
{
|
||||
"name" => query.OrderBy(a => a.Name),
|
||||
"name_desc" => query.OrderByDescending(a => a.Name),
|
||||
_ => query.OrderBy(a => a.Id)
|
||||
};
|
||||
|
||||
// Get total count for pagination
|
||||
var totalCount = await query.CountAsync();
|
||||
|
||||
// Apply pagination
|
||||
var accounts = await query
|
||||
.Skip(request.PageSize * (request.PageToken != null ? int.Parse(request.PageToken) : 0))
|
||||
.Take(request.PageSize)
|
||||
.Include(a => a.Profile)
|
||||
.ToListAsync();
|
||||
|
||||
var response = new ListAccountsResponse
|
||||
{
|
||||
TotalSize = totalCount,
|
||||
NextPageToken = (accounts.Count == request.PageSize)
|
||||
? ((request.PageToken != null ? int.Parse(request.PageToken) : 0) + 1).ToString()
|
||||
: ""
|
||||
};
|
||||
|
||||
response.Accounts.AddRange(accounts.Select(ToProtoAccount));
|
||||
return response;
|
||||
}
|
||||
|
||||
// Implement other service methods following the same pattern...
|
||||
|
||||
// Profile operations
|
||||
public override async Task<Shared.Proto.AccountProfile> GetProfile(GetProfileRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
if (!Guid.TryParse(request.AccountId, out var accountId))
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format"));
|
||||
|
||||
var profile = await _db.AccountProfiles
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.AccountId == accountId);
|
||||
|
||||
if (profile == null)
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound,
|
||||
$"Profile for account {request.AccountId} not found"));
|
||||
|
||||
return ToProtoProfile(profile);
|
||||
}
|
||||
|
||||
public override async Task<Shared.Proto.AccountProfile> UpdateProfile(UpdateProfileRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
if (!Guid.TryParse(request.AccountId, out var accountId))
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format"));
|
||||
|
||||
var profile = await _db.AccountProfiles
|
||||
.FirstOrDefaultAsync(p => p.AccountId == accountId);
|
||||
|
||||
if (profile == null)
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound,
|
||||
$"Profile for account {request.AccountId} not found"));
|
||||
|
||||
// Update only the fields specified in the field mask
|
||||
if (request.UpdateMask == null || request.UpdateMask.Paths.Contains("first_name"))
|
||||
profile.FirstName = request.Profile.FirstName;
|
||||
|
||||
if (request.UpdateMask == null || request.UpdateMask.Paths.Contains("last_name"))
|
||||
profile.LastName = request.Profile.LastName;
|
||||
|
||||
// Update other fields similarly...
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return ToProtoProfile(profile);
|
||||
}
|
||||
|
||||
// Contact operations
|
||||
public override async Task<Shared.Proto.AccountContact> AddContact(AddContactRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
if (!Guid.TryParse(request.AccountId, out var accountId))
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format"));
|
||||
|
||||
var contact = new AccountContact
|
||||
{
|
||||
AccountId = accountId,
|
||||
Type = (AccountContactType)request.Type,
|
||||
Content = request.Content,
|
||||
IsPrimary = request.IsPrimary,
|
||||
VerifiedAt = null
|
||||
};
|
||||
|
||||
_db.AccountContacts.Add(contact);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return ToProtoContact(contact);
|
||||
}
|
||||
|
||||
// Implement other contact operations...
|
||||
|
||||
// Badge operations
|
||||
public override async Task<Shared.Proto.AccountBadge> AddBadge(AddBadgeRequest request, ServerCallContext context)
|
||||
{
|
||||
if (!Guid.TryParse(request.AccountId, out var accountId))
|
||||
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format"));
|
||||
|
||||
var badge = new AccountBadge
|
||||
{
|
||||
AccountId = accountId,
|
||||
Type = request.Type,
|
||||
Label = request.Label,
|
||||
Caption = request.Caption,
|
||||
ActivatedAt = _clock.GetCurrentInstant(),
|
||||
ExpiredAt = request.ExpiredAt?.ToInstant(),
|
||||
Meta = request.Meta.ToDictionary(kvp => kvp.Key, kvp => (object)kvp.Value)
|
||||
};
|
||||
|
||||
_db.Badges.Add(badge);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return ToProtoBadge(badge);
|
||||
}
|
||||
|
||||
// Implement other badge operations...
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
59
DysonNetwork.Pass/Account/ActionLog.cs
Normal file
59
DysonNetwork.Pass/Account/ActionLog.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using DysonNetwork.Shared.Data;
|
||||
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; }
|
||||
}
|
||||
44
DysonNetwork.Pass/Account/ActionLogService.cs
Normal file
44
DysonNetwork.Pass/Account/ActionLogService.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Geo;
|
||||
|
||||
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.AuthSession currentSession)
|
||||
log.SessionId = currentSession.Id;
|
||||
|
||||
fbs.Enqueue(log);
|
||||
}
|
||||
}
|
||||
48
DysonNetwork.Pass/Account/Badge.cs
Normal file
48
DysonNetwork.Pass/Account/Badge.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
public class AccountBadge : 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; }
|
||||
}
|
||||
66
DysonNetwork.Pass/Account/Event.cs
Normal file
66
DysonNetwork.Pass/Account/Event.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using DysonNetwork.Shared.Data;
|
||||
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>();
|
||||
}
|
||||
31
DysonNetwork.Pass/Account/MagicSpell.cs
Normal file
31
DysonNetwork.Pass/Account/MagicSpell.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Shared.Data;
|
||||
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();
|
||||
}
|
||||
}
|
||||
247
DysonNetwork.Pass/Account/MagicSpellService.cs
Normal file
247
DysonNetwork.Pass/Account/MagicSpellService.cs
Normal file
@@ -0,0 +1,247 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using NodaTime;
|
||||
using EmailResource = DysonNetwork.Pass.Localization.EmailResource;
|
||||
|
||||
namespace DysonNetwork.Pass.Account;
|
||||
|
||||
public class MagicSpellService(
|
||||
AppDatabase db,
|
||||
IConfiguration configuration,
|
||||
ILogger<MagicSpellService> logger,
|
||||
IStringLocalizer<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);
|
||||
}
|
||||
}
|
||||
42
DysonNetwork.Pass/Account/Notification.cs
Normal file
42
DysonNetwork.Pass/Account/Notification.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Shared.Data;
|
||||
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!;
|
||||
}
|
||||
167
DysonNetwork.Pass/Account/NotificationController.cs
Normal file
167
DysonNetwork.Pass/Account/NotificationController.cs
Normal file
@@ -0,0 +1,167 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Pass;
|
||||
using DysonNetwork.Pass.Auth;
|
||||
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 AuthSession;
|
||||
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 AuthSession;
|
||||
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();
|
||||
}
|
||||
}
|
||||
292
DysonNetwork.Pass/Account/NotificationService.cs
Normal file
292
DysonNetwork.Pass/Account/NotificationService.cs
Normal file
@@ -0,0 +1,292 @@
|
||||
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,
|
||||
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)
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
23
DysonNetwork.Pass/Account/Relationship.cs
Normal file
23
DysonNetwork.Pass/Account/Relationship.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using DysonNetwork.Shared.Data;
|
||||
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
|
||||
}
|
||||
272
DysonNetwork.Pass/AppDatabase.cs
Normal file
272
DysonNetwork.Pass/AppDatabase.cs
Normal file
@@ -0,0 +1,272 @@
|
||||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
using DysonNetwork.Pass.Account;
|
||||
using DysonNetwork.Pass.Auth;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
using DysonNetwork.Pass.Wallet;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using Microsoft.EntityFrameworkCore.Query;
|
||||
using NodaTime;
|
||||
using Quartz;
|
||||
|
||||
namespace DysonNetwork.Pass;
|
||||
|
||||
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<AccountProfile> 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<AccountBadge> Badges { get; set; }
|
||||
public DbSet<ActionLog> ActionLogs { get; set; }
|
||||
public DbSet<AbuseReport> AbuseReports { get; set; }
|
||||
|
||||
public DbSet<AuthSession> AuthSessions { get; set; }
|
||||
public DbSet<AuthChallenge> AuthChallenges { get; set; }
|
||||
|
||||
public DbSet<Wallet.Wallet> Wallets { get; set; }
|
||||
public DbSet<WalletPocket> WalletPockets { get; set; }
|
||||
public DbSet<Order> PaymentOrders { get; set; }
|
||||
public DbSet<Transaction> PaymentTransactions { get; set; }
|
||||
public DbSet<Subscription> WalletSubscriptions { get; set; }
|
||||
public DbSet<Coupon> WalletCoupons { 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<AuthSession>($"{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<AuthChallenge>> 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 AuthChallenge
|
||||
{
|
||||
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<AuthChallenge>> 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<AuthChallenge>> 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 AuthSession
|
||||
{
|
||||
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<AuthSession> CreateSessionForOidcAsync(Account.Account account, Instant time, Guid? customAppId = null)
|
||||
{
|
||||
var challenge = new AuthChallenge
|
||||
{
|
||||
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 AuthSession
|
||||
{
|
||||
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(AuthSession 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(AuthSession 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(AuthSession 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 AuthSession 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<AuthSession?> 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");
|
||||
|
||||
AuthSession 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,
|
||||
AuthSession 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<AuthSession?> 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(AuthSession 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(
|
||||
AuthSession 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<AuthChallenge>> 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<AuthChallenge> 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 AuthChallenge
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
70
DysonNetwork.Pass/Auth/Session.cs
Normal file
70
DysonNetwork.Pass/Auth/Session.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Pass;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using NodaTime;
|
||||
using Point = NetTopologySuite.Geometries.Point;
|
||||
|
||||
namespace DysonNetwork.Pass.Auth;
|
||||
|
||||
public class AuthSession : 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 AuthChallenge 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 AuthChallenge : 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 AuthChallenge 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"]
|
||||
115
DysonNetwork.Pass/DysonNetwork.Pass.csproj
Normal file
115
DysonNetwork.Pass/DysonNetwork.Pass.csproj
Normal file
@@ -0,0 +1,115 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" />
|
||||
<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.Protobuf" Version="2.0.2" />
|
||||
<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"/>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="Resources\Localization\NotificationResource.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>NotificationResource.resx</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Update="Resources\Localization\SharedResource.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>SharedResource.resx</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Update="Resources\Localization\NotificationResource.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>NotificationResource.resx</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Update="Resources\Localization\SharedResource.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>SharedResource.resx</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Update="Resources\Localization\AccountEventResource.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>AccountEventResource.resx</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Update="Resources\Localization\AccountEventResource.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>AccountEventResource.resx</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Update="Resources\Localization\EmailResource.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Email.LandingResource.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Update="Resources\Localization\NotificationResource.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>NotificationResource.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Update="Resources\Localization\SharedResource.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>SharedResource.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
<EmbeddedResource Update="Resources\Localization\AccountEventResource.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>AccountEventResource.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AdditionalFiles Include="Pages\Emails\AccountDeletionEmail.razor" />
|
||||
<AdditionalFiles Include="Pages\Emails\ContactVerificationEmail.razor" />
|
||||
<AdditionalFiles Include="Pages\Emails\EmailLayout.razor" />
|
||||
<AdditionalFiles Include="Pages\Emails\LandingEmail.razor" />
|
||||
<AdditionalFiles Include="Pages\Emails\PasswordResetEmail.razor" />
|
||||
<AdditionalFiles Include="Pages\Emails\VerificationEmail.razor" />
|
||||
<AdditionalFiles Include="Resources\Localization\AccountEventResource.resx" />
|
||||
<AdditionalFiles Include="Resources\Localization\AccountEventResource.zh-hans.resx" />
|
||||
<AdditionalFiles Include="Resources\Localization\EmailResource.resx" />
|
||||
<AdditionalFiles Include="Resources\Localization\EmailResource.zh-hans.resx" />
|
||||
<AdditionalFiles Include="Resources\Localization\NotificationResource.resx" />
|
||||
<AdditionalFiles Include="Resources\Localization\NotificationResource.zh-hans.resx" />
|
||||
<AdditionalFiles Include="Resources\Localization\SharedResource.resx" />
|
||||
<AdditionalFiles Include="Resources\Localization\SharedResource.zh-hans.resx" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
31
DysonNetwork.Pass/Email/EmailModels.cs
Normal file
31
DysonNetwork.Pass/Email/EmailModels.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
namespace DysonNetwork.Pass.Email;
|
||||
|
||||
public class LandingEmailModel
|
||||
{
|
||||
public required string Name { get; set; }
|
||||
public required string Link { get; set; }
|
||||
}
|
||||
|
||||
public class AccountDeletionEmailModel
|
||||
{
|
||||
public required string Name { get; set; }
|
||||
public required string Link { get; set; }
|
||||
}
|
||||
|
||||
public class PasswordResetEmailModel
|
||||
{
|
||||
public required string Name { get; set; }
|
||||
public required string Link { get; set; }
|
||||
}
|
||||
|
||||
public class VerificationEmailModel
|
||||
{
|
||||
public required string Name { get; set; }
|
||||
public required string Code { get; set; }
|
||||
}
|
||||
|
||||
public class ContactVerificationEmailModel
|
||||
{
|
||||
public required string Name { get; set; }
|
||||
public required string Link { get; set; }
|
||||
}
|
||||
106
DysonNetwork.Pass/Email/EmailService.cs
Normal file
106
DysonNetwork.Pass/Email/EmailService.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using MailKit.Net.Smtp;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using MimeKit;
|
||||
|
||||
namespace DysonNetwork.Pass.Email;
|
||||
|
||||
public class EmailServiceConfiguration
|
||||
{
|
||||
public string Server { get; set; } = null!;
|
||||
public int Port { get; set; }
|
||||
public bool UseSsl { get; set; }
|
||||
public string Username { get; set; } = null!;
|
||||
public string Password { get; set; } = null!;
|
||||
public string FromAddress { get; set; } = null!;
|
||||
public string FromName { get; set; } = null!;
|
||||
public string SubjectPrefix { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class EmailService
|
||||
{
|
||||
private readonly EmailServiceConfiguration _configuration;
|
||||
private readonly RazorViewRenderer _viewRenderer;
|
||||
private readonly ILogger<EmailService> _logger;
|
||||
|
||||
public EmailService(IConfiguration configuration, RazorViewRenderer viewRenderer, ILogger<EmailService> logger)
|
||||
{
|
||||
var cfg = configuration.GetSection("Email").Get<EmailServiceConfiguration>();
|
||||
_configuration = cfg ?? throw new ArgumentException("Email service was not configured.");
|
||||
_viewRenderer = viewRenderer;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task SendEmailAsync(string? recipientName, string recipientEmail, string subject, string textBody)
|
||||
{
|
||||
await SendEmailAsync(recipientName, recipientEmail, subject, textBody, null);
|
||||
}
|
||||
|
||||
public async Task SendEmailAsync(string? recipientName, string recipientEmail, string subject, string textBody,
|
||||
string? htmlBody)
|
||||
{
|
||||
subject = $"[{_configuration.SubjectPrefix}] {subject}";
|
||||
|
||||
var emailMessage = new MimeMessage();
|
||||
emailMessage.From.Add(new MailboxAddress(_configuration.FromName, _configuration.FromAddress));
|
||||
emailMessage.To.Add(new MailboxAddress(recipientName, recipientEmail));
|
||||
emailMessage.Subject = subject;
|
||||
|
||||
var bodyBuilder = new BodyBuilder
|
||||
{
|
||||
TextBody = textBody
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(htmlBody))
|
||||
bodyBuilder.HtmlBody = htmlBody;
|
||||
|
||||
emailMessage.Body = bodyBuilder.ToMessageBody();
|
||||
|
||||
using var client = new SmtpClient();
|
||||
await client.ConnectAsync(_configuration.Server, _configuration.Port, _configuration.UseSsl);
|
||||
await client.AuthenticateAsync(_configuration.Username, _configuration.Password);
|
||||
await client.SendAsync(emailMessage);
|
||||
await client.DisconnectAsync(true);
|
||||
}
|
||||
|
||||
private static string _ConvertHtmlToPlainText(string html)
|
||||
{
|
||||
// Remove style tags and their contents
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, "<style[^>]*>.*?</style>", "",
|
||||
System.Text.RegularExpressions.RegexOptions.Singleline);
|
||||
|
||||
// Replace header tags with text + newlines
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, "<h[1-6][^>]*>(.*?)</h[1-6]>", "$1\n\n",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
// Replace line breaks
|
||||
html = html.Replace("<br>", "\n").Replace("<br/>", "\n").Replace("<br />", "\n");
|
||||
|
||||
// Remove all remaining HTML tags
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", "");
|
||||
|
||||
// Decode HTML entities
|
||||
html = System.Net.WebUtility.HtmlDecode(html);
|
||||
|
||||
// Remove excess whitespace
|
||||
html = System.Text.RegularExpressions.Regex.Replace(html, @"\s+", " ").Trim();
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
public async Task SendTemplatedEmailAsync<TComponent, TModel>(string? recipientName, string recipientEmail,
|
||||
string subject, TModel model)
|
||||
where TComponent : IComponent
|
||||
{
|
||||
try
|
||||
{
|
||||
var htmlBody = await _viewRenderer.RenderComponentToStringAsync<TComponent, TModel>(model);
|
||||
var fallbackTextBody = _ConvertHtmlToPlainText(htmlBody);
|
||||
await SendEmailAsync(recipientName, recipientEmail, subject, fallbackTextBody, htmlBody);
|
||||
}
|
||||
catch (Exception err)
|
||||
{
|
||||
_logger.LogError(err, "Failed to render email template...");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
45
DysonNetwork.Pass/Email/RazorViewRenderer.cs
Normal file
45
DysonNetwork.Pass/Email/RazorViewRenderer.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Microsoft.AspNetCore.Mvc.Razor;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.AspNetCore.Mvc.ViewEngines;
|
||||
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
||||
using RouteData = Microsoft.AspNetCore.Routing.RouteData;
|
||||
|
||||
namespace DysonNetwork.Pass.Email;
|
||||
|
||||
public class RazorViewRenderer(
|
||||
IServiceProvider serviceProvider,
|
||||
ILoggerFactory loggerFactory,
|
||||
ILogger<RazorViewRenderer> logger
|
||||
)
|
||||
{
|
||||
public async Task<string> RenderComponentToStringAsync<TComponent, TModel>(TModel? model)
|
||||
where TComponent : IComponent
|
||||
{
|
||||
await using var htmlRenderer = new HtmlRenderer(serviceProvider, loggerFactory);
|
||||
|
||||
return await htmlRenderer.Dispatcher.InvokeAsync(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var dictionary = model?.GetType().GetProperties()
|
||||
.ToDictionary(
|
||||
prop => prop.Name,
|
||||
prop => prop.GetValue(model, null)
|
||||
) ?? new Dictionary<string, object?>();
|
||||
var parameterView = ParameterView.FromDictionary(dictionary);
|
||||
var output = await htmlRenderer.RenderComponentAsync<TComponent>(parameterView);
|
||||
return output.ToHtmlString();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error rendering component {ComponentName}", typeof(TComponent).Name);
|
||||
throw;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
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.AuthSession 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);
|
||||
}
|
||||
}
|
||||
6
DysonNetwork.Pass/Localization/AccountEventResource.cs
Normal file
6
DysonNetwork.Pass/Localization/AccountEventResource.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace DysonNetwork.Pass.Localization;
|
||||
|
||||
public class AccountEventResource
|
||||
{
|
||||
|
||||
}
|
||||
5
DysonNetwork.Pass/Localization/EmailResource.cs
Normal file
5
DysonNetwork.Pass/Localization/EmailResource.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace DysonNetwork.Pass.Localization;
|
||||
|
||||
public class EmailResource
|
||||
{
|
||||
}
|
||||
6
DysonNetwork.Pass/Localization/NotificationResource.cs
Normal file
6
DysonNetwork.Pass/Localization/NotificationResource.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace DysonNetwork.Pass.Localization;
|
||||
|
||||
public class NotificationResource
|
||||
{
|
||||
|
||||
}
|
||||
6
DysonNetwork.Pass/Localization/SharedResource.cs
Normal file
6
DysonNetwork.Pass/Localization/SharedResource.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace DysonNetwork.Pass.Localization;
|
||||
|
||||
public class SharedResource
|
||||
{
|
||||
|
||||
}
|
||||
42
DysonNetwork.Pass/Pages/Emails/AccountDeletionEmail.razor
Normal file
42
DysonNetwork.Pass/Pages/Emails/AccountDeletionEmail.razor
Normal file
@@ -0,0 +1,42 @@
|
||||
@using DysonNetwork.Pass.Localization
|
||||
@using Microsoft.Extensions.Localization
|
||||
|
||||
<EmailLayout>
|
||||
<tr>
|
||||
<td class="wrapper">
|
||||
<p class="font-bold">@(Localizer["AccountDeletionHeader"])</p>
|
||||
<p>@(Localizer["AccountDeletionPara1"]) @@@Name,</p>
|
||||
<p>@(Localizer["AccountDeletionPara2"])</p>
|
||||
<p>@(Localizer["AccountDeletionPara3"])</p>
|
||||
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="@Link" target="_blank">
|
||||
@(Localizer["AccountDeletionButton"])
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>@(Localizer["AccountDeletionPara4"])</p>
|
||||
</td>
|
||||
</tr>
|
||||
</EmailLayout>
|
||||
|
||||
@code {
|
||||
[Parameter] public required string Name { get; set; }
|
||||
[Parameter] public required string Link { get; set; }
|
||||
|
||||
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
@using DysonNetwork.Pass.Localization
|
||||
@using Microsoft.Extensions.Localization
|
||||
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
|
||||
|
||||
<EmailLayout>
|
||||
<tr>
|
||||
<td class="wrapper">
|
||||
<p class="font-bold">@(Localizer["ContactVerificationHeader"])</p>
|
||||
<p>@(Localizer["ContactVerificationPara1"]) @Name,</p>
|
||||
<p>@(Localizer["ContactVerificationPara2"])</p>
|
||||
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="@Link" target="_blank">
|
||||
@(Localizer["ContactVerificationButton"])
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>@(Localizer["ContactVerificationPara3"])</p>
|
||||
<p>@(Localizer["ContactVerificationPara4"])</p>
|
||||
</td>
|
||||
</tr>
|
||||
</EmailLayout>
|
||||
|
||||
@code {
|
||||
[Parameter] public required string Name { get; set; }
|
||||
[Parameter] public required string Link { get; set; }
|
||||
|
||||
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
|
||||
}
|
||||
337
DysonNetwork.Pass/Pages/Emails/EmailLayout.razor
Normal file
337
DysonNetwork.Pass/Pages/Emails/EmailLayout.razor
Normal file
@@ -0,0 +1,337 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<style media="all" type="text/css">
|
||||
body {
|
||||
font-family: Helvetica, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-size: 16px;
|
||||
line-height: 1.3;
|
||||
-ms-text-size-adjust: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: separate;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
table td {
|
||||
font-family: Helvetica, sans-serif;
|
||||
font-size: 16px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f4f5f6;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
background-color: #f4f5f6;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: 0 auto !important;
|
||||
max-width: 600px;
|
||||
padding: 0;
|
||||
padding-top: 24px;
|
||||
width: 600px;
|
||||
}
|
||||
|
||||
.content {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
max-width: 600px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.main {
|
||||
background: #ffffff;
|
||||
border: 1px solid #eaebed;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
box-sizing: border-box;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
clear: both;
|
||||
padding-top: 24px;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer td,
|
||||
.footer p,
|
||||
.footer span,
|
||||
.footer a {
|
||||
color: #9a9ea6;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
p {
|
||||
font-family: Helvetica, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
margin: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0867ec;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn {
|
||||
box-sizing: border-box;
|
||||
min-width: 100% !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn > tbody > tr > td {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.btn table {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.btn table td {
|
||||
background-color: #ffffff;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn a {
|
||||
background-color: #ffffff;
|
||||
border: solid 2px #0867ec;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
color: #0867ec;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
padding: 12px 24px;
|
||||
text-decoration: none;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.btn-primary table td {
|
||||
background-color: #0867ec;
|
||||
}
|
||||
|
||||
.btn-primary a {
|
||||
background-color: #0867ec;
|
||||
border-color: #0867ec;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.verification-code
|
||||
{
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-size: 24px;
|
||||
letter-spacing: 0.5em;
|
||||
}
|
||||
|
||||
@@media all {
|
||||
.btn-primary table td:hover {
|
||||
background-color: #ec0867 !important;
|
||||
}
|
||||
|
||||
.btn-primary a:hover {
|
||||
background-color: #ec0867 !important;
|
||||
border-color: #ec0867 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.last {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.first {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.align-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.align-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.align-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.text-link {
|
||||
color: #0867ec !important;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
.clear {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.mt0 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.mb0 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.preheader {
|
||||
color: transparent;
|
||||
display: none;
|
||||
height: 0;
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
mso-hide: all;
|
||||
visibility: hidden;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.powered-by a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@media only screen and (max-width: 640px) {
|
||||
.main p,
|
||||
.main td,
|
||||
.main span {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
padding: 8px !important;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 !important;
|
||||
padding-top: 8px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.main {
|
||||
border-left-width: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
border-right-width: 0 !important;
|
||||
}
|
||||
|
||||
.btn table {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.btn a {
|
||||
font-size: 16px !important;
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@media all {
|
||||
.ExternalClass {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ExternalClass,
|
||||
.ExternalClass p,
|
||||
.ExternalClass span,
|
||||
.ExternalClass font,
|
||||
.ExternalClass td,
|
||||
.ExternalClass div {
|
||||
line-height: 100%;
|
||||
}
|
||||
|
||||
.apple-link a {
|
||||
color: inherit !important;
|
||||
font-family: inherit !important;
|
||||
font-size: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
line-height: inherit !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
#MessageViewBody a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body">
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td class="container">
|
||||
<div class="content">
|
||||
|
||||
<!-- START CENTERED WHITE CONTAINER -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="main">
|
||||
<!-- START MAIN CONTENT AREA -->
|
||||
@ChildContent
|
||||
<!-- END MAIN CONTENT AREA -->
|
||||
</table>
|
||||
|
||||
<!-- START FOOTER -->
|
||||
<div class="footer">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td class="content-block">
|
||||
<span class="apple-link">Solar Network</span>
|
||||
<br> Solsynth LLC © @(DateTime.Now.Year)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="content-block powered-by">
|
||||
Powered by <a href="https://github.com/solsynth/dysonnetwork">Dyson Network</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- END FOOTER -->
|
||||
|
||||
<!-- END CENTERED WHITE CONTAINER --></div>
|
||||
</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@code {
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
}
|
||||
43
DysonNetwork.Pass/Pages/Emails/LandingEmail.razor
Normal file
43
DysonNetwork.Pass/Pages/Emails/LandingEmail.razor
Normal file
@@ -0,0 +1,43 @@
|
||||
@using DysonNetwork.Pass.Localization
|
||||
@using Microsoft.Extensions.Localization
|
||||
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
|
||||
|
||||
<EmailLayout>
|
||||
<tr>
|
||||
<td class="wrapper">
|
||||
<p class="font-bold">@(Localizer["LandingHeader1"])</p>
|
||||
<p>@(Localizer["LandingPara1"]) @@@Name,</p>
|
||||
<p>@(Localizer["LandingPara2"])</p>
|
||||
<p>@(Localizer["LandingPara3"])</p>
|
||||
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="@Link" target="_blank">
|
||||
@(Localizer["LandingButton1"])
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>@(Localizer["LandingPara4"])</p>
|
||||
</td>
|
||||
</tr>
|
||||
</EmailLayout>
|
||||
|
||||
@code {
|
||||
[Parameter] public required string Name { get; set; }
|
||||
[Parameter] public required string Link { get; set; }
|
||||
|
||||
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
|
||||
}
|
||||
44
DysonNetwork.Pass/Pages/Emails/PasswordResetEmail.razor
Normal file
44
DysonNetwork.Pass/Pages/Emails/PasswordResetEmail.razor
Normal file
@@ -0,0 +1,44 @@
|
||||
@using DysonNetwork.Pass.Localization
|
||||
@using Microsoft.Extensions.Localization
|
||||
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
|
||||
|
||||
<EmailLayout>
|
||||
<tr>
|
||||
<td class="wrapper">
|
||||
<p class="font-bold">@(Localizer["PasswordResetHeader"])</p>
|
||||
<p>@(Localizer["PasswordResetPara1"]) @@@Name,</p>
|
||||
<p>@(Localizer["PasswordResetPara2"])</p>
|
||||
<p>@(Localizer["PasswordResetPara3"])</p>
|
||||
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="@Link" target="_blank">
|
||||
@(Localizer["PasswordResetButton"])
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>@(Localizer["PasswordResetPara4"])</p>
|
||||
</td>
|
||||
</tr>
|
||||
</EmailLayout>
|
||||
|
||||
@code {
|
||||
[Parameter] public required string Name { get; set; }
|
||||
[Parameter] public required string Link { get; set; }
|
||||
|
||||
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
|
||||
[Inject] IStringLocalizer<SharedResource> LocalizerShared { get; set; } = null!;
|
||||
}
|
||||
27
DysonNetwork.Pass/Pages/Emails/VerificationEmail.razor
Normal file
27
DysonNetwork.Pass/Pages/Emails/VerificationEmail.razor
Normal file
@@ -0,0 +1,27 @@
|
||||
@using DysonNetwork.Pass.Localization
|
||||
@using Microsoft.Extensions.Localization
|
||||
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
|
||||
|
||||
<EmailLayout>
|
||||
<tr>
|
||||
<td class="wrapper">
|
||||
<p class="font-bold">@(Localizer["VerificationHeader1"])</p>
|
||||
<p>@(Localizer["VerificationPara1"]) @@@Name,</p>
|
||||
<p>@(Localizer["VerificationPara2"])</p>
|
||||
<p>@(Localizer["VerificationPara3"])</p>
|
||||
|
||||
<p class="verification-code">@Code</p>
|
||||
|
||||
<p>@(Localizer["VerificationPara4"])</p>
|
||||
<p>@(Localizer["VerificationPara5"])</p>
|
||||
</td>
|
||||
</tr>
|
||||
</EmailLayout>
|
||||
|
||||
@code {
|
||||
[Parameter] public required string Name { get; set; }
|
||||
[Parameter] public required string Code { get; set; }
|
||||
|
||||
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
|
||||
[Inject] IStringLocalizer<SharedResource> LocalizerShared { get; set; } = null!;
|
||||
}
|
||||
60
DysonNetwork.Pass/Permission/Permission.cs
Normal file
60
DysonNetwork.Pass/Permission/Permission.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Shared.Data;
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
44
DysonNetwork.Pass/Program.cs
Normal file
44
DysonNetwork.Pass/Program.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using DysonNetwork.Pass;
|
||||
using DysonNetwork.Pass.Account;
|
||||
using DysonNetwork.Pass.Startup;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Configure Kestrel and server options
|
||||
builder.ConfigureAppKestrel();
|
||||
|
||||
// Add metrics and telemetry
|
||||
builder.Services.AddAppMetrics();
|
||||
|
||||
// Add application services
|
||||
builder.Services.AddAppServices(builder.Configuration);
|
||||
builder.Services.AddAppRateLimiting();
|
||||
builder.Services.AddAppAuthentication();
|
||||
builder.Services.AddAppSwagger();
|
||||
|
||||
// Add flush handlers and websocket handlers
|
||||
builder.Services.AddAppFlushHandlers();
|
||||
|
||||
// Add business services
|
||||
builder.Services.AddAppBusinessServices(builder.Configuration);
|
||||
|
||||
// Add scheduled jobs
|
||||
builder.Services.AddAppScheduledJobs();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Run database migrations
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
// Configure application middleware pipeline
|
||||
app.ConfigureAppMiddleware(builder.Configuration);
|
||||
|
||||
// Configure gRPC
|
||||
app.ConfigureGrpcServices();
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
222
DysonNetwork.Pass/Resources/Localization/AccountEventResource.Designer.cs
generated
Normal file
222
DysonNetwork.Pass/Resources/Localization/AccountEventResource.Designer.cs
generated
Normal file
@@ -0,0 +1,222 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace DysonNetwork.Pass.Resources {
|
||||
using System;
|
||||
|
||||
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
internal class AccountEventResource {
|
||||
|
||||
private static System.Resources.ResourceManager resourceMan;
|
||||
|
||||
private static System.Globalization.CultureInfo resourceCulture;
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
|
||||
internal AccountEventResource() {
|
||||
}
|
||||
|
||||
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static System.Resources.ResourceManager ResourceManager {
|
||||
get {
|
||||
if (object.Equals(null, resourceMan)) {
|
||||
System.Resources.ResourceManager temp = new System.Resources.ResourceManager("DysonNetwork.Pass.Resources.AccountEventResource", typeof(AccountEventResource).Assembly);
|
||||
resourceMan = temp;
|
||||
}
|
||||
return resourceMan;
|
||||
}
|
||||
}
|
||||
|
||||
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static System.Globalization.CultureInfo Culture {
|
||||
get {
|
||||
return resourceCulture;
|
||||
}
|
||||
set {
|
||||
resourceCulture = value;
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipPositiveTitle_1 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipPositiveTitle_1", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipPositiveContent_1 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipPositiveContent_1", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipNegativeTitle_1 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipNegativeTitle_1", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipNegativeContent_1 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipNegativeContent_1", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipPositiveTitle_2 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipPositiveTitle_2", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipPositiveContent_2 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipPositiveContent_2", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipNegativeTitle_2 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipNegativeTitle_2", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipNegativeContent_2 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipNegativeContent_2", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipPositiveTitle_3 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipPositiveTitle_3", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipPositiveContent_3 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipPositiveContent_3", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipNegativeTitle_3 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipNegativeTitle_3", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipNegativeContent_3 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipNegativeContent_3", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipPositiveTitle_4 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipPositiveTitle_4", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipPositiveContent_4 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipPositiveContent_4", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipNegativeTitle_4 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipNegativeTitle_4", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipNegativeContent_4 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipNegativeContent_4", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipPositiveTitle_5 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipPositiveTitle_5", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipPositiveContent_5 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipPositiveContent_5", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipNegativeTitle_5 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipNegativeTitle_5", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipNegativeContent_5 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipNegativeContent_5", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipPositiveTitle_6 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipPositiveTitle_6", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipPositiveContent_6 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipPositiveContent_6", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipNegativeTitle_6 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipNegativeTitle_6", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipNegativeContent_6 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipNegativeContent_6", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipPositiveTitle_7 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipPositiveTitle_7", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipPositiveContent_7 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipPositiveContent_7", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipNegativeTitle_7 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipNegativeTitle_7", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipNegativeContent_7 {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipNegativeContent_7", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string FortuneTipNegativeTitle_1_ {
|
||||
get {
|
||||
return ResourceManager.GetString("FortuneTipNegativeTitle_1 ", resourceCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<root>
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral,
|
||||
PublicKeyToken=b77a5c561934e089
|
||||
</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral,
|
||||
PublicKeyToken=b77a5c561934e089
|
||||
</value>
|
||||
</resheader>
|
||||
<data name="FortuneTipPositiveTitle_1" xml:space="preserve">
|
||||
<value>Gacha</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_1" xml:space="preserve">
|
||||
<value>Golden every pull</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_1" xml:space="preserve">
|
||||
<value>Gacha</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_1" xml:space="preserve">
|
||||
<value>Won't pull the card you like</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_2" xml:space="preserve">
|
||||
<value>Gaming</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_2" xml:space="preserve">
|
||||
<value>Rank up like a hot knife through butter</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_2" xml:space="preserve">
|
||||
<value>Gaming</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_2" xml:space="preserve">
|
||||
<value>Dropping ranks like a landslide</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_3" xml:space="preserve">
|
||||
<value>Lottery</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_3" xml:space="preserve">
|
||||
<value>Blessed with luck</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_3" xml:space="preserve">
|
||||
<value>Lottery</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_3" xml:space="preserve">
|
||||
<value>Ten pulls, all silence</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_4" xml:space="preserve">
|
||||
<value>Speech</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_4" xml:space="preserve">
|
||||
<value>Words flow like gems</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_4" xml:space="preserve">
|
||||
<value>Speech</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_4" xml:space="preserve">
|
||||
<value>Be careful what you're saying</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_5" xml:space="preserve">
|
||||
<value>Drawing</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_5" xml:space="preserve">
|
||||
<value>Inspiration gushes like a spring</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_5" xml:space="preserve">
|
||||
<value>Drawing</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_5" xml:space="preserve">
|
||||
<value>Every stroke weighs a thousand pounds</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_6" xml:space="preserve">
|
||||
<value>Coding</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_6" xml:space="preserve">
|
||||
<value>0 error(s), 0 warning(s)</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_6" xml:space="preserve">
|
||||
<value>Coding</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_6" xml:space="preserve">
|
||||
<value>114 error(s), 514 warning(s)</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_7" xml:space="preserve">
|
||||
<value>Shopping</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_7" xml:space="preserve">
|
||||
<value>Exchange rate at its lowest</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_7" xml:space="preserve">
|
||||
<value>Unboxing</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_7" xml:space="preserve">
|
||||
<value>225% tariff</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_1 " xml:space="preserve">
|
||||
<value>Gacha</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -0,0 +1,98 @@
|
||||
<root>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="FortuneTipPositiveTitle_1" xml:space="preserve">
|
||||
<value>抽卡</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_1" xml:space="preserve">
|
||||
<value>次次出金</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_1" xml:space="preserve">
|
||||
<value>吃大保底</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_2" xml:space="preserve">
|
||||
<value>游戏</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_2" xml:space="preserve">
|
||||
<value>升段如破竹</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_2" xml:space="preserve">
|
||||
<value>游戏</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_2" xml:space="preserve">
|
||||
<value>掉分如山崩</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_3" xml:space="preserve">
|
||||
<value>抽奖</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_3" xml:space="preserve">
|
||||
<value>欧气加身</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_3" xml:space="preserve">
|
||||
<value>抽奖</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_3" xml:space="preserve">
|
||||
<value>十连皆寂</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_4" xml:space="preserve">
|
||||
<value>演讲</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_4" xml:space="preserve">
|
||||
<value>妙语连珠</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_4" xml:space="preserve">
|
||||
<value>演讲</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_4" xml:space="preserve">
|
||||
<value>谨言慎行</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_5" xml:space="preserve">
|
||||
<value>绘图</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_5" xml:space="preserve">
|
||||
<value>灵感如泉涌</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_5" xml:space="preserve">
|
||||
<value>绘图</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_5" xml:space="preserve">
|
||||
<value>下笔如千斤</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_6" xml:space="preserve">
|
||||
<value>编程</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_6" xml:space="preserve">
|
||||
<value>0 error(s), 0 warning(s)</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_6" xml:space="preserve">
|
||||
<value>编程</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_6" xml:space="preserve">
|
||||
<value>114 error(s), 514 warning(s)</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveTitle_7" xml:space="preserve">
|
||||
<value>购物</value>
|
||||
</data>
|
||||
<data name="FortuneTipPositiveContent_7" xml:space="preserve">
|
||||
<value>汇率低谷</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_7" xml:space="preserve">
|
||||
<value>开箱</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeContent_7" xml:space="preserve">
|
||||
<value>225% 关税</value>
|
||||
</data>
|
||||
<data name="FortuneTipNegativeTitle_1" xml:space="preserve">
|
||||
<value>抽卡</value>
|
||||
</data>
|
||||
</root>
|
||||
90
DysonNetwork.Pass/Resources/Localization/EmailResource.Designer.cs
generated
Normal file
90
DysonNetwork.Pass/Resources/Localization/EmailResource.Designer.cs
generated
Normal file
@@ -0,0 +1,90 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace DysonNetwork.Pass.Resources.Pages.Emails {
|
||||
using System;
|
||||
|
||||
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
internal class EmailResource {
|
||||
|
||||
private static System.Resources.ResourceManager resourceMan;
|
||||
|
||||
private static System.Globalization.CultureInfo resourceCulture;
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
|
||||
internal EmailResource() {
|
||||
}
|
||||
|
||||
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static System.Resources.ResourceManager ResourceManager {
|
||||
get {
|
||||
if (object.Equals(null, resourceMan)) {
|
||||
System.Resources.ResourceManager temp = new System.Resources.ResourceManager("DysonNetwork.Pass.Resources.Localization.EmailResource", typeof(EmailResource).Assembly);
|
||||
resourceMan = temp;
|
||||
}
|
||||
return resourceMan;
|
||||
}
|
||||
}
|
||||
|
||||
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static System.Globalization.CultureInfo Culture {
|
||||
get {
|
||||
return resourceCulture;
|
||||
}
|
||||
set {
|
||||
resourceCulture = value;
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LandingHeader1 {
|
||||
get {
|
||||
return ResourceManager.GetString("LandingHeader1", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LandingPara1 {
|
||||
get {
|
||||
return ResourceManager.GetString("LandingPara1", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LandingPara2 {
|
||||
get {
|
||||
return ResourceManager.GetString("LandingPara2", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LandingPara3 {
|
||||
get {
|
||||
return ResourceManager.GetString("LandingPara3", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LandingButton1 {
|
||||
get {
|
||||
return ResourceManager.GetString("LandingButton1", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string LandingPara4 {
|
||||
get {
|
||||
return ResourceManager.GetString("LandingPara4", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string EmailLandingTitle {
|
||||
get {
|
||||
return ResourceManager.GetString("EmailLandingTitle", resourceCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
126
DysonNetwork.Pass/Resources/Localization/EmailResource.resx
Normal file
126
DysonNetwork.Pass/Resources/Localization/EmailResource.resx
Normal file
@@ -0,0 +1,126 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<root>
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="LandingHeader1" xml:space="preserve">
|
||||
<value>Welcome to the Solar Network!</value>
|
||||
</data>
|
||||
<data name="LandingPara1" xml:space="preserve">
|
||||
<value>Dear, </value>
|
||||
</data>
|
||||
<data name="LandingPara2" xml:space="preserve">
|
||||
<value>Thank you for creating an account on the Solar Network. We're excited to have you join our community!</value>
|
||||
</data>
|
||||
<data name="LandingPara3" xml:space="preserve">
|
||||
<value>To access all features and ensure the security of your account, please confirm your registration by clicking the button below:</value>
|
||||
</data>
|
||||
<data name="LandingButton1" xml:space="preserve">
|
||||
<value>Confirm Registration</value>
|
||||
</data>
|
||||
<data name="LandingPara4" xml:space="preserve">
|
||||
<value>If you didn't create this account, please ignore this email.</value>
|
||||
</data>
|
||||
<data name="EmailLandingTitle" xml:space="preserve">
|
||||
<value>Confirm your registration</value>
|
||||
</data>
|
||||
<data name="AccountDeletionHeader" xml:space="preserve">
|
||||
<value>Account Deletion Confirmation</value>
|
||||
</data>
|
||||
<data name="AccountDeletionPara1" xml:space="preserve">
|
||||
<value>Dear, </value>
|
||||
</data>
|
||||
<data name="AccountDeletionPara2" xml:space="preserve">
|
||||
<value>We've received a request to delete your Solar Network account. We're sorry to see you go.</value>
|
||||
</data>
|
||||
<data name="AccountDeletionPara3" xml:space="preserve">
|
||||
<value>To confirm your account deletion, please click the button below. Please note that this action is permanent and cannot be undone.</value>
|
||||
</data>
|
||||
<data name="AccountDeletionButton" xml:space="preserve">
|
||||
<value>Confirm Account Deletion</value>
|
||||
</data>
|
||||
<data name="AccountDeletionPara4" xml:space="preserve">
|
||||
<value>If you did not request to delete your account, please ignore this email or contact our support team immediately.</value>
|
||||
</data>
|
||||
<data name="EmailAccountDeletionTitle" xml:space="preserve">
|
||||
<value>Confirm your account deletion</value>
|
||||
</data>
|
||||
<data name="EmailPasswordResetTitle" xml:space="preserve">
|
||||
<value>Reset your password</value>
|
||||
</data>
|
||||
<data name="PasswordResetButton" xml:space="preserve">
|
||||
<value>Reset Password</value>
|
||||
</data>
|
||||
<data name="PasswordResetHeader" xml:space="preserve">
|
||||
<value>Password Reset Request</value>
|
||||
</data>
|
||||
<data name="PasswordResetPara1" xml:space="preserve">
|
||||
<value>Dear,</value>
|
||||
</data>
|
||||
<data name="PasswordResetPara2" xml:space="preserve">
|
||||
<value>We recieved a request to reset your Solar Network account password.</value>
|
||||
</data>
|
||||
<data name="PasswordResetPara3" xml:space="preserve">
|
||||
<value>You can click the button below to continue reset your password.</value>
|
||||
</data>
|
||||
<data name="PasswordResetPara4" xml:space="preserve">
|
||||
<value>If you didn't request this, you can ignore this email safety.</value>
|
||||
</data>
|
||||
<data name="VerificationHeader1" xml:space="preserve">
|
||||
<value>Verify Your Email Address</value>
|
||||
</data>
|
||||
<data name="VerificationPara1" xml:space="preserve">
|
||||
<value>Dear, </value>
|
||||
</data>
|
||||
<data name="VerificationPara2" xml:space="preserve">
|
||||
<value>Thank you for creating an account on the Solar Network. We're excited to have you join our community!</value>
|
||||
</data>
|
||||
<data name="VerificationPara3" xml:space="preserve">
|
||||
<value>To verify your email address and access all features of your account, please use the verification code below:</value>
|
||||
</data>
|
||||
<data name="VerificationPara4" xml:space="preserve">
|
||||
<value>This code will expire in 30 minutes. Please enter it on the verification page to complete your registration.</value>
|
||||
</data>
|
||||
<data name="VerificationPara5" xml:space="preserve">
|
||||
<value>If you didn't create this account, please ignore this email.</value>
|
||||
</data>
|
||||
<data name="EmailVerificationTitle" xml:space="preserve">
|
||||
<value>Verify your email address</value>
|
||||
</data>
|
||||
<data name="ContactVerificationHeader" xml:space="preserve">
|
||||
<value>Verify Your Contact Information</value>
|
||||
</data>
|
||||
<data name="ContactVerificationPara1" xml:space="preserve">
|
||||
<value>Dear, </value>
|
||||
</data>
|
||||
<data name="ContactVerificationPara2" xml:space="preserve">
|
||||
<value>Thank you for updating your contact information on the Solar Network. To ensure your account security, we need to verify this change.</value>
|
||||
</data>
|
||||
<data name="ContactVerificationPara3" xml:space="preserve">
|
||||
<value>Please click the button below to verify your contact information:</value>
|
||||
</data>
|
||||
<data name="ContactVerificationButton" xml:space="preserve">
|
||||
<value>Verify Contact Information</value>
|
||||
</data>
|
||||
<data name="ContactVerificationPara4" xml:space="preserve">
|
||||
<value>If you didn't request this change, please contact our support team immediately.</value>
|
||||
</data>
|
||||
<data name="EmailContactVerificationTitle" xml:space="preserve">
|
||||
<value>Verify your contact information</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -0,0 +1,119 @@
|
||||
<root>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="LandingHeader1" xml:space="preserve">
|
||||
<value>欢迎来到 Solar Network!</value>
|
||||
</data>
|
||||
<data name="LandingPara1" xml:space="preserve">
|
||||
<value>尊敬的 </value>
|
||||
</data>
|
||||
<data name="LandingButton1" xml:space="preserve">
|
||||
<value>确认注册</value>
|
||||
</data>
|
||||
<data name="LandingPara2" xml:space="preserve">
|
||||
<value>感谢你在 Solar Network 上注册帐号,我们很激动你即将加入我们的社区!</value>
|
||||
</data>
|
||||
<data name="LandingPara3" xml:space="preserve">
|
||||
<value>点击下方按钮来确认你的注册以获得所有功能的权限。</value>
|
||||
</data>
|
||||
<data name="LandingPara4" xml:space="preserve">
|
||||
<value>如果你并没有注册帐号,你可以忽略此邮件。</value>
|
||||
</data>
|
||||
<data name="EmailLandingTitle" xml:space="preserve">
|
||||
<value>确认你的注册</value>
|
||||
</data>
|
||||
<data name="AccountDeletionHeader" xml:space="preserve">
|
||||
<value>账户删除确认</value>
|
||||
</data>
|
||||
<data name="AccountDeletionPara1" xml:space="preserve">
|
||||
<value>尊敬的 </value>
|
||||
</data>
|
||||
<data name="AccountDeletionPara2" xml:space="preserve">
|
||||
<value>我们收到了删除您 Solar Network 账户的请求。我们很遗憾看到您的离开。</value>
|
||||
</data>
|
||||
<data name="AccountDeletionPara3" xml:space="preserve">
|
||||
<value>请点击下方按钮确认删除您的账户。请注意,此操作是永久性的,无法撤销。</value>
|
||||
</data>
|
||||
<data name="AccountDeletionButton" xml:space="preserve">
|
||||
<value>确认删除账户</value>
|
||||
</data>
|
||||
<data name="AccountDeletionPara4" xml:space="preserve">
|
||||
<value>如果您并未请求删除账户,请忽略此邮件或立即联系我们的支持团队。</value>
|
||||
</data>
|
||||
<data name="EmailAccountDeletionTitle" xml:space="preserve">
|
||||
<value>确认删除您的账户</value>
|
||||
</data>
|
||||
<data name="PasswordResetHeader" xml:space="preserve">
|
||||
<value>密码重置请求</value>
|
||||
</data>
|
||||
<data name="PasswordResetPara1" xml:space="preserve">
|
||||
<value>尊敬的 </value>
|
||||
</data>
|
||||
<data name="PasswordResetPara2" xml:space="preserve">
|
||||
<value>我们收到了重置您 Solar Network 账户密码的请求。</value>
|
||||
</data>
|
||||
<data name="PasswordResetPara3" xml:space="preserve">
|
||||
<value>请点击下方按钮重置您的密码。此链接将在24小时后失效。</value>
|
||||
</data>
|
||||
<data name="PasswordResetButton" xml:space="preserve">
|
||||
<value>重置密码</value>
|
||||
</data>
|
||||
<data name="PasswordResetPara4" xml:space="preserve">
|
||||
<value>如果您并未请求重置密码,你可以安全地忽略此邮件。</value>
|
||||
</data>
|
||||
<data name="EmailPasswordResetTitle" xml:space="preserve">
|
||||
<value>重置您的密码</value>
|
||||
</data>
|
||||
<data name="VerificationHeader1" xml:space="preserve">
|
||||
<value>验证您的电子邮箱</value>
|
||||
</data>
|
||||
<data name="VerificationPara1" xml:space="preserve">
|
||||
<value>尊敬的 </value>
|
||||
</data>
|
||||
<data name="VerificationPara2" xml:space="preserve">
|
||||
<value>感谢您在 Solar Network 上注册账号,我们很高兴您即将加入我们的社区!</value>
|
||||
</data>
|
||||
<data name="VerificationPara3" xml:space="preserve">
|
||||
<value>请使用以下验证码来验证您的电子邮箱并获取账号的所有功能:</value>
|
||||
</data>
|
||||
<data name="VerificationPara4" xml:space="preserve">
|
||||
<value>此验证码将在30分钟后失效。请在验证页面输入此验证码以完成注册。</value>
|
||||
</data>
|
||||
<data name="VerificationPara5" xml:space="preserve">
|
||||
<value>如果您并未创建此账号,请忽略此邮件。</value>
|
||||
</data>
|
||||
<data name="EmailVerificationTitle" xml:space="preserve">
|
||||
<value>验证您的电子邮箱</value>
|
||||
</data>
|
||||
<data name="ContactVerificationHeader" xml:space="preserve">
|
||||
<value>验证您的联系信息</value>
|
||||
</data>
|
||||
<data name="ContactVerificationPara1" xml:space="preserve">
|
||||
<value>尊敬的 </value>
|
||||
</data>
|
||||
<data name="ContactVerificationPara2" xml:space="preserve">
|
||||
<value>感谢您更新 Solar Network 上的联系信息。为确保您的账户安全,我们需要验证此更改。</value>
|
||||
</data>
|
||||
<data name="ContactVerificationPara3" xml:space="preserve">
|
||||
<value>请点击下方按钮验证您的联系信息:</value>
|
||||
</data>
|
||||
<data name="ContactVerificationButton" xml:space="preserve">
|
||||
<value>验证联系信息</value>
|
||||
</data>
|
||||
<data name="ContactVerificationPara4" xml:space="preserve">
|
||||
<value>如果您没有请求此更改,请立即联系我们的支持团队。</value>
|
||||
</data>
|
||||
<data name="EmailContactVerificationTitle" xml:space="preserve">
|
||||
<value>验证您的联系信息</value>
|
||||
</data>
|
||||
</root>
|
||||
162
DysonNetwork.Pass/Resources/Localization/NotificationResource.Designer.cs
generated
Normal file
162
DysonNetwork.Pass/Resources/Localization/NotificationResource.Designer.cs
generated
Normal file
@@ -0,0 +1,162 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace DysonNetwork.Pass.Resources.Localization {
|
||||
using System;
|
||||
|
||||
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
internal class NotificationResource {
|
||||
|
||||
private static System.Resources.ResourceManager resourceMan;
|
||||
|
||||
private static System.Globalization.CultureInfo resourceCulture;
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
|
||||
internal NotificationResource() {
|
||||
}
|
||||
|
||||
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static System.Resources.ResourceManager ResourceManager {
|
||||
get {
|
||||
if (object.Equals(null, resourceMan)) {
|
||||
System.Resources.ResourceManager temp = new System.Resources.ResourceManager("DysonNetwork.Pass.Resources.Localization.NotificationResource", typeof(NotificationResource).Assembly);
|
||||
resourceMan = temp;
|
||||
}
|
||||
return resourceMan;
|
||||
}
|
||||
}
|
||||
|
||||
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static System.Globalization.CultureInfo Culture {
|
||||
get {
|
||||
return resourceCulture;
|
||||
}
|
||||
set {
|
||||
resourceCulture = value;
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ChatInviteTitle {
|
||||
get {
|
||||
return ResourceManager.GetString("ChatInviteTitle", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ChatInviteBody {
|
||||
get {
|
||||
return ResourceManager.GetString("ChatInviteBody", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ChatInviteDirectBody {
|
||||
get {
|
||||
return ResourceManager.GetString("ChatInviteDirectBody", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string RealmInviteTitle {
|
||||
get {
|
||||
return ResourceManager.GetString("RealmInviteTitle", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string RealmInviteBody {
|
||||
get {
|
||||
return ResourceManager.GetString("RealmInviteBody", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string PostSubscriptionTitle {
|
||||
get {
|
||||
return ResourceManager.GetString("PostSubscriptionTitle", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string PostReactTitle {
|
||||
get {
|
||||
return ResourceManager.GetString("PostReactTitle", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string PostReactBody {
|
||||
get {
|
||||
return ResourceManager.GetString("PostReactBody", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string PostReactContentBody {
|
||||
get {
|
||||
return ResourceManager.GetString("PostReactContentBody", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string PostReplyTitle {
|
||||
get {
|
||||
return ResourceManager.GetString("PostReplyTitle", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string PostReplyBody {
|
||||
get {
|
||||
return ResourceManager.GetString("PostReplyBody", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string PostReplyContentBody {
|
||||
get {
|
||||
return ResourceManager.GetString("PostReplyContentBody", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string PostOnlyMedia {
|
||||
get {
|
||||
return ResourceManager.GetString("PostOnlyMedia", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string AuthCodeTitle {
|
||||
get {
|
||||
return ResourceManager.GetString("AuthCodeTitle", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string AuthCodeBody {
|
||||
get {
|
||||
return ResourceManager.GetString("AuthCodeBody", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string SubscriptionAppliedTitle {
|
||||
get {
|
||||
return ResourceManager.GetString("SubscriptionAppliedTitle", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string SubscriptionAppliedBody {
|
||||
get {
|
||||
return ResourceManager.GetString("SubscriptionAppliedBody", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string OrderPaidTitle {
|
||||
get {
|
||||
return ResourceManager.GetString("OrderPaidTitle", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string OrderPaidBody {
|
||||
get {
|
||||
return ResourceManager.GetString("OrderPaidBody", resourceCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<root>
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral,
|
||||
PublicKeyToken=b77a5c561934e089
|
||||
</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral,
|
||||
PublicKeyToken=b77a5c561934e089
|
||||
</value>
|
||||
</resheader>
|
||||
<data name="ChatInviteTitle" xml:space="preserve">
|
||||
<value>New Chat Invitation</value>
|
||||
</data>
|
||||
<data name="ChatInviteBody" xml:space="preserve">
|
||||
<value>You just got invited to join {0}</value>
|
||||
</data>
|
||||
<data name="ChatInviteDirectBody" xml:space="preserve">
|
||||
<value>{0} sent an direct message invitation to you</value>
|
||||
</data>
|
||||
<data name="RealmInviteTitle" xml:space="preserve">
|
||||
<value>New Realm Invitation</value>
|
||||
</data>
|
||||
<data name="RealmInviteBody" xml:space="preserve">
|
||||
<value>You just got invited to join {0}</value>
|
||||
</data>
|
||||
<data name="PostSubscriptionTitle" xml:space="preserve">
|
||||
<value>{0} just posted {1}</value>
|
||||
</data>
|
||||
<data name="PostReactTitle" xml:space="preserve">
|
||||
<value>{0} reacted your post</value>
|
||||
</data>
|
||||
<data name="PostReactBody" xml:space="preserve">
|
||||
<value>{0} added a reaction {1} to your post</value>
|
||||
</data>
|
||||
<data name="PostReactContentBody" xml:space="preserve">
|
||||
<value>{0} added a reaction {1} to your post {2}</value>
|
||||
</data>
|
||||
<data name="PostReplyTitle" xml:space="preserve">
|
||||
<value>{0} replied your post</value>
|
||||
</data>
|
||||
<data name="PostReplyBody">
|
||||
<value>{0} replied: {1}</value>
|
||||
</data>
|
||||
<data name="PostReplyContentBody">
|
||||
<value>{0} replied post {1}: {2}</value>
|
||||
</data>
|
||||
<data name="PostOnlyMedia" xml:space="preserve">
|
||||
<value>shared media</value>
|
||||
</data>
|
||||
<data name="AuthCodeTitle" xml:space="preserve">
|
||||
<value>Disposable Verification Code</value>
|
||||
</data>
|
||||
<data name="AuthCodeBody" xml:space="preserve">
|
||||
<value>{0} is your disposable code, it will expires in 5 minutes</value>
|
||||
</data>
|
||||
<data name="SubscriptionAppliedTitle" xml:space="preserve">
|
||||
<value>Subscription {0} just activated for your account</value>
|
||||
</data>
|
||||
<data name="SubscriptionAppliedBody" xml:space="preserve">
|
||||
<value>Thank for supporting the Solar Network! Your {0} days {1} subscription just begun, feel free to explore the newly unlocked features!</value>
|
||||
</data>
|
||||
<data name="OrderPaidTitle" xml:space="preserve">
|
||||
<value>Order {0} recipent</value>
|
||||
</data>
|
||||
<data name="OrderPaidBody" xml:space="preserve">
|
||||
<value>{0} {1} was removed from your wallet to pay {2}</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -0,0 +1,75 @@
|
||||
<root>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral,
|
||||
PublicKeyToken=b77a5c561934e089
|
||||
</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral,
|
||||
PublicKeyToken=b77a5c561934e089
|
||||
</value>
|
||||
</resheader>
|
||||
<data name="ChatInviteTitle" xml:space="preserve">
|
||||
<value>新聊天邀请</value>
|
||||
</data>
|
||||
<data name="ChatInviteBody" xml:space="preserve">
|
||||
<value>你刚被邀请加入聊天 {}</value>
|
||||
</data>
|
||||
<data name="ChatInviteDirectBody" xml:space="preserve">
|
||||
<value>{0} 向你发送了一个私聊邀请</value>
|
||||
</data>
|
||||
<data name="RealmInviteTitle" xml:space="preserve">
|
||||
<value>新加入领域邀请</value>
|
||||
</data>
|
||||
<data name="RealmInviteBody" xml:space="preserve">
|
||||
<value>你刚被邀请加入领域 {0}</value>
|
||||
</data>
|
||||
<data name="PostSubscriptionTitle" xml:space="preserve">
|
||||
<value>{0} 有新帖子</value>
|
||||
</data>
|
||||
<data name="PostReactTitle" xml:space="preserve">
|
||||
<value>{0} 反应了你的帖子</value>
|
||||
</data>
|
||||
<data name="PostReactBody" xml:space="preserve">
|
||||
<value>{0} 给你的帖子添加了一个 {1} 的反应</value>
|
||||
</data>
|
||||
<data name="PostReactContentBody" xml:space="preserve">
|
||||
<value>{0} 给你的帖子添加了一个 {1} 的反应 {2}</value>
|
||||
</data>
|
||||
<data name="PostReplyTitle" xml:space="preserve">
|
||||
<value>{0} 回复了你的帖子</value>
|
||||
</data>
|
||||
<data name="PostReplyBody">
|
||||
<value>{0}:{1}</value>
|
||||
</data>
|
||||
<data name="PostReplyContentBody">
|
||||
<value>{0} 回复了帖子 {1}: {2}</value>
|
||||
</data>
|
||||
<data name="PostOnlyMedia" xml:space="preserve">
|
||||
<value>分享媒体</value>
|
||||
</data>
|
||||
<data name="AuthCodeTitle" xml:space="preserve">
|
||||
<value>一次性验证码</value>
|
||||
</data>
|
||||
<data name="AuthCodeBody" xml:space="preserve">
|
||||
<value>{0} 是你的一次性验证码,它将会在五分钟内过期</value>
|
||||
</data>
|
||||
<data name="SubscriptionAppliedTitle" xml:space="preserve">
|
||||
<value>{0} 的订阅激活成功</value>
|
||||
</data>
|
||||
<data name="SubscriptionAppliedBody" xml:space="preserve">
|
||||
<value>感谢你支持 Solar Network 的开发!你的 {0} 天 {1} 订阅刚刚开始,接下来来探索新解锁的新功能吧!</value>
|
||||
</data>
|
||||
<data name="OrderPaidTitle" xml:space="preserve">
|
||||
<value>订单回执 {0}</value>
|
||||
</data>
|
||||
<data name="OrderPaidBody" xml:space="preserve">
|
||||
<value>{0} {1} 已从你的帐户中扣除来支付 {2}</value>
|
||||
</data>
|
||||
</root>
|
||||
48
DysonNetwork.Pass/Resources/Localization/SharedResource.Designer.cs
generated
Normal file
48
DysonNetwork.Pass/Resources/Localization/SharedResource.Designer.cs
generated
Normal file
@@ -0,0 +1,48 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace DysonNetwork.Pass.Resources {
|
||||
using System;
|
||||
|
||||
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
internal class SharedResource {
|
||||
|
||||
private static System.Resources.ResourceManager resourceMan;
|
||||
|
||||
private static System.Globalization.CultureInfo resourceCulture;
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
|
||||
internal SharedResource() {
|
||||
}
|
||||
|
||||
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static System.Resources.ResourceManager ResourceManager {
|
||||
get {
|
||||
if (object.Equals(null, resourceMan)) {
|
||||
System.Resources.ResourceManager temp = new System.Resources.ResourceManager("DysonNetwork.Pass.Resources.Localization.SharedResource", typeof(SharedResource).Assembly);
|
||||
resourceMan = temp;
|
||||
}
|
||||
return resourceMan;
|
||||
}
|
||||
}
|
||||
|
||||
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static System.Globalization.CultureInfo Culture {
|
||||
get {
|
||||
return resourceCulture;
|
||||
}
|
||||
set {
|
||||
resourceCulture = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
DysonNetwork.Pass/Resources/Localization/SharedResource.resx
Normal file
21
DysonNetwork.Pass/Resources/Localization/SharedResource.resx
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<root>
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
</root>
|
||||
@@ -0,0 +1,14 @@
|
||||
<root>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>1.3</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
</root>
|
||||
74
DysonNetwork.Pass/Startup/ApplicationConfiguration.cs
Normal file
74
DysonNetwork.Pass/Startup/ApplicationConfiguration.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using System.Net;
|
||||
using DysonNetwork.Pass.Account;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Prometheus;
|
||||
|
||||
namespace DysonNetwork.Pass.Startup;
|
||||
|
||||
public static class ApplicationConfiguration
|
||||
{
|
||||
public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration)
|
||||
{
|
||||
app.MapMetrics();
|
||||
app.MapOpenApi();
|
||||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
|
||||
app.UseRequestLocalization();
|
||||
|
||||
ConfigureForwardedHeaders(app, configuration);
|
||||
|
||||
app.UseCors(opts =>
|
||||
opts.SetIsOriginAllowed(_ => true)
|
||||
.WithExposedHeaders("*")
|
||||
.WithHeaders()
|
||||
.AllowCredentials()
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
);
|
||||
|
||||
app.UseWebSockets();
|
||||
app.UseRateLimiter();
|
||||
app.UseHttpsRedirection();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseMiddleware<PermissionMiddleware>();
|
||||
|
||||
app.MapControllers().RequireRateLimiting("fixed");
|
||||
app.MapStaticAssets().RequireRateLimiting("fixed");
|
||||
app.MapRazorPages().RequireRateLimiting("fixed");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static void ConfigureForwardedHeaders(WebApplication app, IConfiguration configuration)
|
||||
{
|
||||
var knownProxiesSection = configuration.GetSection("KnownProxies");
|
||||
var forwardedHeadersOptions = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All };
|
||||
|
||||
if (knownProxiesSection.Exists())
|
||||
{
|
||||
var proxyAddresses = knownProxiesSection.Get<string[]>();
|
||||
if (proxyAddresses != null)
|
||||
foreach (var proxy in proxyAddresses)
|
||||
if (IPAddress.TryParse(proxy, out var ipAddress))
|
||||
forwardedHeadersOptions.KnownProxies.Add(ipAddress);
|
||||
}
|
||||
else
|
||||
{
|
||||
forwardedHeadersOptions.KnownProxies.Add(IPAddress.Any);
|
||||
forwardedHeadersOptions.KnownProxies.Add(IPAddress.IPv6Any);
|
||||
}
|
||||
|
||||
app.UseForwardedHeaders(forwardedHeadersOptions);
|
||||
}
|
||||
|
||||
public static WebApplication ConfigureGrpcServices(this WebApplication app)
|
||||
{
|
||||
app.MapGrpcService<AccountServiceGrpc>();
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
17
DysonNetwork.Pass/Startup/KestrelConfiguration.cs
Normal file
17
DysonNetwork.Pass/Startup/KestrelConfiguration.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace DysonNetwork.Pass.Startup;
|
||||
|
||||
public static class KestrelConfiguration
|
||||
{
|
||||
public static WebApplicationBuilder ConfigureAppKestrel(this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Host.UseContentRoot(Directory.GetCurrentDirectory());
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
{
|
||||
options.Limits.MaxRequestBodySize = 50 * 1024 * 1024;
|
||||
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2);
|
||||
options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
40
DysonNetwork.Pass/Startup/MetricsConfiguration.cs
Normal file
40
DysonNetwork.Pass/Startup/MetricsConfiguration.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Trace;
|
||||
using Prometheus;
|
||||
using Prometheus.SystemMetrics;
|
||||
|
||||
namespace DysonNetwork.Pass.Startup;
|
||||
|
||||
public static class MetricsConfiguration
|
||||
{
|
||||
public static IServiceCollection AddAppMetrics(this IServiceCollection services)
|
||||
{
|
||||
// Prometheus
|
||||
services.UseHttpClientMetrics();
|
||||
services.AddHealthChecks();
|
||||
services.AddSystemMetrics();
|
||||
services.AddPrometheusEntityFrameworkMetrics();
|
||||
services.AddPrometheusAspNetCoreMetrics();
|
||||
services.AddPrometheusHttpClientMetrics();
|
||||
|
||||
// OpenTelemetry
|
||||
services.AddOpenTelemetry()
|
||||
.WithTracing(tracing =>
|
||||
{
|
||||
tracing
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddOtlpExporter();
|
||||
})
|
||||
.WithMetrics(metrics =>
|
||||
{
|
||||
metrics
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation()
|
||||
.AddOtlpExporter();
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
54
DysonNetwork.Pass/Startup/ScheduledJobsConfiguration.cs
Normal file
54
DysonNetwork.Pass/Startup/ScheduledJobsConfiguration.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using DysonNetwork.Pass.Handlers;
|
||||
using DysonNetwork.Pass.Wallet;
|
||||
using Quartz;
|
||||
|
||||
namespace DysonNetwork.Pass.Startup;
|
||||
|
||||
public static class ScheduledJobsConfiguration
|
||||
{
|
||||
public static IServiceCollection AddAppScheduledJobs(this IServiceCollection services)
|
||||
{
|
||||
services.AddQuartz(q =>
|
||||
{
|
||||
var appDatabaseRecyclingJob = new JobKey("AppDatabaseRecycling");
|
||||
q.AddJob<AppDatabaseRecyclingJob>(opts => opts.WithIdentity(appDatabaseRecyclingJob));
|
||||
q.AddTrigger(opts => opts
|
||||
.ForJob(appDatabaseRecyclingJob)
|
||||
.WithIdentity("AppDatabaseRecyclingTrigger")
|
||||
.WithCronSchedule("0 0 0 * * ?"));
|
||||
|
||||
var actionLogFlushJob = new JobKey("ActionLogFlush");
|
||||
q.AddJob<ActionLogFlushJob>(opts => opts.WithIdentity(actionLogFlushJob));
|
||||
q.AddTrigger(opts => opts
|
||||
.ForJob(actionLogFlushJob)
|
||||
.WithIdentity("ActionLogFlushTrigger")
|
||||
.WithSimpleSchedule(o => o
|
||||
.WithIntervalInMinutes(5)
|
||||
.RepeatForever())
|
||||
);
|
||||
|
||||
var lastActiveFlushJob = new JobKey("LastActiveFlush");
|
||||
q.AddJob<LastActiveFlushJob>(opts => opts.WithIdentity(lastActiveFlushJob));
|
||||
q.AddTrigger(opts => opts
|
||||
.ForJob(lastActiveFlushJob)
|
||||
.WithIdentity("LastActiveFlushTrigger")
|
||||
.WithSimpleSchedule(o => o
|
||||
.WithIntervalInMinutes(5)
|
||||
.RepeatForever())
|
||||
);
|
||||
|
||||
var subscriptionRenewalJob = new JobKey("SubscriptionRenewal");
|
||||
q.AddJob<SubscriptionRenewalJob>(opts => opts.WithIdentity(subscriptionRenewalJob));
|
||||
q.AddTrigger(opts => opts
|
||||
.ForJob(subscriptionRenewalJob)
|
||||
.WithIdentity("SubscriptionRenewalTrigger")
|
||||
.WithSimpleSchedule(o => o
|
||||
.WithIntervalInMinutes(30)
|
||||
.RepeatForever())
|
||||
);
|
||||
});
|
||||
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
209
DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs
Normal file
209
DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs
Normal file
@@ -0,0 +1,209 @@
|
||||
using System.Globalization;
|
||||
using DysonNetwork.Pass.Account;
|
||||
using DysonNetwork.Pass.Auth;
|
||||
using DysonNetwork.Pass.Auth.OpenId;
|
||||
using DysonNetwork.Pass.Email;
|
||||
using DysonNetwork.Pass.Localization;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
using DysonNetwork.Pass.Wallet;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using NodaTime;
|
||||
using NodaTime.Serialization.SystemTextJson;
|
||||
using StackExchange.Redis;
|
||||
using System.Text.Json;
|
||||
using System.Threading.RateLimiting;
|
||||
using DysonNetwork.Pass.Auth.OidcProvider.Options;
|
||||
using DysonNetwork.Pass.Auth.OidcProvider.Services;
|
||||
using DysonNetwork.Pass.Handlers;
|
||||
using DysonNetwork.Pass.Wallet.PaymentHandlers;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Geo;
|
||||
|
||||
namespace DysonNetwork.Pass.Startup;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddLocalization(options => options.ResourcesPath = "Resources");
|
||||
|
||||
services.AddDbContext<AppDatabase>();
|
||||
services.AddSingleton<IConnectionMultiplexer>(_ =>
|
||||
{
|
||||
var connection = configuration.GetConnectionString("FastRetrieve")!;
|
||||
return ConnectionMultiplexer.Connect(connection);
|
||||
});
|
||||
services.AddSingleton<IClock>(SystemClock.Instance);
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddSingleton<ICacheService, CacheServiceRedis>();
|
||||
|
||||
services.AddHttpClient();
|
||||
|
||||
// Register gRPC services
|
||||
services.AddGrpc(options =>
|
||||
{
|
||||
options.EnableDetailedErrors = true; // Will be adjusted in Program.cs
|
||||
options.MaxReceiveMessageSize = 16 * 1024 * 1024; // 16MB
|
||||
options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB
|
||||
});
|
||||
|
||||
// Register gRPC reflection for service discovery
|
||||
services.AddGrpc();
|
||||
|
||||
// Register gRPC services
|
||||
services.AddScoped<AccountServiceGrpc>();
|
||||
|
||||
// Register OIDC services
|
||||
services.AddScoped<OidcService, GoogleOidcService>();
|
||||
services.AddScoped<OidcService, AppleOidcService>();
|
||||
services.AddScoped<OidcService, GitHubOidcService>();
|
||||
services.AddScoped<OidcService, MicrosoftOidcService>();
|
||||
services.AddScoped<OidcService, DiscordOidcService>();
|
||||
services.AddScoped<OidcService, AfdianOidcService>();
|
||||
services.AddScoped<GoogleOidcService>();
|
||||
services.AddScoped<AppleOidcService>();
|
||||
services.AddScoped<GitHubOidcService>();
|
||||
services.AddScoped<MicrosoftOidcService>();
|
||||
services.AddScoped<DiscordOidcService>();
|
||||
services.AddScoped<AfdianOidcService>();
|
||||
|
||||
services.AddControllers().AddJsonOptions(options =>
|
||||
{
|
||||
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||
|
||||
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
|
||||
}).AddDataAnnotationsLocalization(options =>
|
||||
{
|
||||
options.DataAnnotationLocalizerProvider = (type, factory) =>
|
||||
factory.Create(typeof(SharedResource));
|
||||
});
|
||||
services.AddRazorPages();
|
||||
|
||||
services.Configure<RequestLocalizationOptions>(options =>
|
||||
{
|
||||
var supportedCultures = new[]
|
||||
{
|
||||
new CultureInfo("en-US"),
|
||||
new CultureInfo("zh-Hans"),
|
||||
};
|
||||
|
||||
options.SupportedCultures = supportedCultures;
|
||||
options.SupportedUICultures = supportedCultures;
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAppRateLimiting(this IServiceCollection services)
|
||||
{
|
||||
services.AddRateLimiter(o => o.AddFixedWindowLimiter(policyName: "fixed", opts =>
|
||||
{
|
||||
opts.Window = TimeSpan.FromMinutes(1);
|
||||
opts.PermitLimit = 120;
|
||||
opts.QueueLimit = 2;
|
||||
opts.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
||||
}));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
|
||||
{
|
||||
services.AddCors();
|
||||
services.AddAuthorization();
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = AuthConstants.SchemeName;
|
||||
options.DefaultChallengeScheme = AuthConstants.SchemeName;
|
||||
})
|
||||
.AddScheme<DysonTokenAuthOptions, DysonTokenAuthHandler>(AuthConstants.SchemeName, _ => { });
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAppSwagger(this IServiceCollection services)
|
||||
{
|
||||
services.AddEndpointsApiExplorer();
|
||||
services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.SwaggerDoc("v1", new OpenApiInfo
|
||||
{
|
||||
Version = "v1",
|
||||
Title = "Solar Network API",
|
||||
Description = "An open-source social network",
|
||||
TermsOfService = new Uri("https://solsynth.dev/terms"),
|
||||
License = new OpenApiLicense
|
||||
{
|
||||
Name = "APGLv3",
|
||||
Url = new Uri("https://www.gnu.org/licenses/agpl-3.0.html")
|
||||
}
|
||||
});
|
||||
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||
{
|
||||
In = ParameterLocation.Header,
|
||||
Description = "Please enter a valid token",
|
||||
Name = "Authorization",
|
||||
Type = SecuritySchemeType.Http,
|
||||
BearerFormat = "JWT",
|
||||
Scheme = "Bearer"
|
||||
});
|
||||
options.AddSecurityRequirement(new OpenApiSecurityRequirement
|
||||
{
|
||||
{
|
||||
new OpenApiSecurityScheme
|
||||
{
|
||||
Reference = new OpenApiReference
|
||||
{
|
||||
Type = ReferenceType.SecurityScheme,
|
||||
Id = "Bearer"
|
||||
}
|
||||
},
|
||||
[]
|
||||
}
|
||||
});
|
||||
});
|
||||
services.AddOpenApi();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAppFlushHandlers(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<FlushBufferService>();
|
||||
services.AddScoped<ActionLogFlushHandler>();
|
||||
services.AddScoped<LastActiveFlushHandler>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAppBusinessServices(this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.AddScoped<CompactTokenService>();
|
||||
services.AddScoped<RazorViewRenderer>();
|
||||
services.Configure<GeoIpOptions>(configuration.GetSection("GeoIP"));
|
||||
services.AddScoped<GeoIpService>();
|
||||
services.AddScoped<EmailService>();
|
||||
services.AddScoped<PermissionService>();
|
||||
services.AddScoped<ActionLogService>();
|
||||
services.AddScoped<AccountService>();
|
||||
services.AddScoped<AccountEventService>();
|
||||
services.AddScoped<ActionLogService>();
|
||||
services.AddScoped<RelationshipService>();
|
||||
services.AddScoped<MagicSpellService>();
|
||||
services.AddScoped<NotificationService>();
|
||||
services.AddScoped<AuthService>();
|
||||
services.AddScoped<AccountUsernameService>();
|
||||
services.AddScoped<WalletService>();
|
||||
services.AddScoped<SubscriptionService>();
|
||||
services.AddScoped<PaymentService>();
|
||||
services.AddScoped<AfdianPaymentHandler>();
|
||||
|
||||
services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider"));
|
||||
services.AddScoped<OidcProviderService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
57
DysonNetwork.Pass/Wallet/OrderController.cs
Normal file
57
DysonNetwork.Pass/Wallet/OrderController.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using DysonNetwork.Pass.Auth;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Pass.Wallet;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/orders")]
|
||||
public class OrderController(PaymentService payment, AuthService auth, AppDatabase db) : ControllerBase
|
||||
{
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<Order>> GetOrderById(Guid id)
|
||||
{
|
||||
var order = await db.PaymentOrders.FindAsync(id);
|
||||
|
||||
if (order == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return Ok(order);
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/pay")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Order>> PayOrder(Guid id, [FromBody] PayOrderRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser ||
|
||||
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
|
||||
|
||||
// Validate PIN code
|
||||
if (!await auth.ValidatePinCode(currentUser.Id, request.PinCode))
|
||||
return StatusCode(403, "Invalid PIN Code");
|
||||
|
||||
try
|
||||
{
|
||||
// Get the wallet for the current user
|
||||
var wallet = await db.Wallets.FirstOrDefaultAsync(w => w.AccountId == currentUser.Id);
|
||||
if (wallet == null)
|
||||
return BadRequest("Wallet was not found.");
|
||||
|
||||
// Pay the order
|
||||
var paidOrder = await payment.PayOrderAsync(id, wallet.Id);
|
||||
return Ok(paidOrder);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class PayOrderRequest
|
||||
{
|
||||
public string PinCode { get; set; } = string.Empty;
|
||||
}
|
||||
61
DysonNetwork.Pass/Wallet/Payment.cs
Normal file
61
DysonNetwork.Pass/Wallet/Payment.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Wallet;
|
||||
|
||||
public class WalletCurrency
|
||||
{
|
||||
public const string SourcePoint = "points";
|
||||
public const string GoldenPoint = "golds";
|
||||
}
|
||||
|
||||
public enum OrderStatus
|
||||
{
|
||||
Unpaid,
|
||||
Paid,
|
||||
Cancelled,
|
||||
Finished,
|
||||
Expired
|
||||
}
|
||||
|
||||
public class Order : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public OrderStatus Status { get; set; } = OrderStatus.Unpaid;
|
||||
[MaxLength(128)] public string Currency { get; set; } = null!;
|
||||
[MaxLength(4096)] public string? Remarks { get; set; }
|
||||
[MaxLength(4096)] public string? AppIdentifier { get; set; }
|
||||
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public Instant ExpiredAt { get; set; }
|
||||
|
||||
public Guid? PayeeWalletId { get; set; }
|
||||
public Wallet? PayeeWallet { get; set; } = null!;
|
||||
public Guid? TransactionId { get; set; }
|
||||
public Transaction? Transaction { get; set; }
|
||||
}
|
||||
|
||||
public enum TransactionType
|
||||
{
|
||||
System,
|
||||
Transfer,
|
||||
Order
|
||||
}
|
||||
|
||||
public class Transaction : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(128)] public string Currency { get; set; } = null!;
|
||||
public decimal Amount { get; set; }
|
||||
[MaxLength(4096)] public string? Remarks { get; set; }
|
||||
public TransactionType Type { get; set; }
|
||||
|
||||
// When the payer is null, it's pay from the system
|
||||
public Guid? PayerWalletId { get; set; }
|
||||
public Wallet? PayerWallet { get; set; }
|
||||
// When the payee is null, it's pay for the system
|
||||
public Guid? PayeeWalletId { get; set; }
|
||||
public Wallet? PayeeWallet { get; set; }
|
||||
}
|
||||
446
DysonNetwork.Pass/Wallet/PaymentHandlers/AfdianPaymentHandler.cs
Normal file
446
DysonNetwork.Pass/Wallet/PaymentHandlers/AfdianPaymentHandler.cs
Normal file
@@ -0,0 +1,446 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Wallet.PaymentHandlers;
|
||||
|
||||
public class AfdianPaymentHandler(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<AfdianPaymentHandler> logger,
|
||||
IConfiguration configuration
|
||||
)
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory =
|
||||
httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
|
||||
private readonly ILogger<AfdianPaymentHandler> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
private readonly IConfiguration _configuration =
|
||||
configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private string CalculateSign(string token, string userId, string paramsJson, long ts)
|
||||
{
|
||||
var kvString = $"{token}params{paramsJson}ts{ts}user_id{userId}";
|
||||
using (var md5 = MD5.Create())
|
||||
{
|
||||
var hashBytes = md5.ComputeHash(Encoding.UTF8.GetBytes(kvString));
|
||||
return BitConverter.ToString(hashBytes).Replace("-", "").ToLower();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<OrderResponse?> ListOrderAsync(int page = 1)
|
||||
{
|
||||
try
|
||||
{
|
||||
var token = _configuration["Payment:Auth:Afdian"] ?? "_:_";
|
||||
var tokenParts = token.Split(':');
|
||||
var userId = tokenParts[0];
|
||||
token = tokenParts[1];
|
||||
var paramsJson = JsonSerializer.Serialize(new { page }, JsonOptions);
|
||||
var ts = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1))
|
||||
.TotalSeconds; // Current timestamp in seconds
|
||||
|
||||
var sign = CalculateSign(token, userId, paramsJson, ts);
|
||||
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/open/query-order")
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(new
|
||||
{
|
||||
user_id = userId,
|
||||
@params = paramsJson,
|
||||
ts,
|
||||
sign
|
||||
}, JsonOptions), Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError(
|
||||
$"Response Error: {response.StatusCode}, {await response.Content.ReadAsStringAsync()}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = await JsonSerializer.DeserializeAsync<OrderResponse>(
|
||||
await response.Content.ReadAsStreamAsync(), JsonOptions);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching orders");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a specific order by its ID (out_trade_no)
|
||||
/// </summary>
|
||||
/// <param name="orderId">The order ID to query</param>
|
||||
/// <returns>The order item if found, otherwise null</returns>
|
||||
public async Task<OrderItem?> GetOrderAsync(string orderId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(orderId))
|
||||
{
|
||||
_logger.LogWarning("Order ID cannot be null or empty");
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var token = _configuration["Payment:Auth:Afdian"] ?? "_:_";
|
||||
var tokenParts = token.Split(':');
|
||||
var userId = tokenParts[0];
|
||||
token = tokenParts[1];
|
||||
var paramsJson = JsonSerializer.Serialize(new { out_trade_no = orderId }, JsonOptions);
|
||||
var ts = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1))
|
||||
.TotalSeconds; // Current timestamp in seconds
|
||||
|
||||
var sign = CalculateSign(token, userId, paramsJson, ts);
|
||||
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/open/query-order")
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(new
|
||||
{
|
||||
user_id = userId,
|
||||
@params = paramsJson,
|
||||
ts,
|
||||
sign
|
||||
}, JsonOptions), Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError(
|
||||
$"Response Error: {response.StatusCode}, {await response.Content.ReadAsStringAsync()}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = await JsonSerializer.DeserializeAsync<OrderResponse>(
|
||||
await response.Content.ReadAsStreamAsync(), JsonOptions);
|
||||
|
||||
// Check if we have a valid response and orders in the list
|
||||
if (result?.Data.Orders == null || result.Data.Orders.Count == 0)
|
||||
{
|
||||
_logger.LogWarning($"No order found with ID: {orderId}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Since we're querying by a specific order ID, we should only get one result
|
||||
return result.Data.Orders.FirstOrDefault();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Error fetching order with ID: {orderId}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get multiple orders by their IDs (out_trade_no)
|
||||
/// </summary>
|
||||
/// <param name="orderIds">A collection of order IDs to query</param>
|
||||
/// <returns>A list of found order items</returns>
|
||||
public async Task<List<OrderItem>> GetOrderBatchAsync(IEnumerable<string> orderIds)
|
||||
{
|
||||
var orders = orderIds.ToList();
|
||||
if (orders.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("Order IDs cannot be null or empty");
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Join the order IDs with commas as specified in the API documentation
|
||||
var orderIdsParam = string.Join(",", orders);
|
||||
|
||||
var token = _configuration["Payment:Auth:Afdian"] ?? "_:_";
|
||||
var tokenParts = token.Split(':');
|
||||
var userId = tokenParts[0];
|
||||
token = tokenParts[1];
|
||||
var paramsJson = JsonSerializer.Serialize(new { out_trade_no = orderIdsParam }, JsonOptions);
|
||||
var ts = (long)(DateTime.UtcNow - new DateTime(1970, 1, 1))
|
||||
.TotalSeconds; // Current timestamp in seconds
|
||||
|
||||
var sign = CalculateSign(token, userId, paramsJson, ts);
|
||||
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/api/open/query-order")
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(new
|
||||
{
|
||||
user_id = userId,
|
||||
@params = paramsJson,
|
||||
ts,
|
||||
sign
|
||||
}, JsonOptions), Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError(
|
||||
$"Response Error: {response.StatusCode}, {await response.Content.ReadAsStringAsync()}");
|
||||
return new List<OrderItem>();
|
||||
}
|
||||
|
||||
var result = await JsonSerializer.DeserializeAsync<OrderResponse>(
|
||||
await response.Content.ReadAsStreamAsync(), JsonOptions);
|
||||
|
||||
// Check if we have a valid response and orders in the list
|
||||
if (result?.Data?.Orders != null && result.Data.Orders.Count != 0) return result.Data.Orders;
|
||||
_logger.LogWarning($"No orders found with IDs: {orderIdsParam}");
|
||||
return [];
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Error fetching orders");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle an incoming webhook from Afdian's payment platform
|
||||
/// </summary>
|
||||
/// <param name="request">The HTTP request containing webhook data</param>
|
||||
/// <param name="processOrderAction">An action to process the received order</param>
|
||||
/// <returns>A WebhookResponse object to be returned to Afdian</returns>
|
||||
public async Task<WebhookResponse> HandleWebhook(
|
||||
HttpRequest request,
|
||||
Func<WebhookOrderData, Task>? processOrderAction
|
||||
)
|
||||
{
|
||||
_logger.LogInformation("Received webhook request from afdian...");
|
||||
|
||||
try
|
||||
{
|
||||
// Read the request body
|
||||
string requestBody;
|
||||
using (var reader = new StreamReader(request.Body, Encoding.UTF8))
|
||||
{
|
||||
requestBody = await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(requestBody))
|
||||
{
|
||||
_logger.LogError("Webhook request body is empty");
|
||||
return new WebhookResponse { ErrorCode = 400, ErrorMessage = "Empty request body" };
|
||||
}
|
||||
|
||||
_logger.LogInformation($"Received webhook: {requestBody}");
|
||||
|
||||
// Parse the webhook data
|
||||
var webhook = JsonSerializer.Deserialize<WebhookRequest>(requestBody, JsonOptions);
|
||||
|
||||
if (webhook == null)
|
||||
{
|
||||
_logger.LogError("Failed to parse webhook data");
|
||||
return new WebhookResponse { ErrorCode = 400, ErrorMessage = "Invalid webhook data" };
|
||||
}
|
||||
|
||||
// Validate the webhook type
|
||||
if (webhook.Data.Type != "order")
|
||||
{
|
||||
_logger.LogWarning($"Unsupported webhook type: {webhook.Data.Type}");
|
||||
return WebhookResponse.Success;
|
||||
}
|
||||
|
||||
// Process the order
|
||||
try
|
||||
{
|
||||
// Check for duplicate order processing by storing processed order IDs
|
||||
// (You would implement a more permanent storage mechanism for production)
|
||||
if (processOrderAction != null)
|
||||
await processOrderAction(webhook.Data);
|
||||
else
|
||||
_logger.LogInformation(
|
||||
$"Order received but no processing action provided: {webhook.Data.Order.TradeNumber}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Error processing order {webhook.Data.Order.TradeNumber}");
|
||||
// Still returning success to Afdian to prevent repeated callbacks
|
||||
// Your system should handle the error internally
|
||||
}
|
||||
|
||||
// Return success response to Afdian
|
||||
return WebhookResponse.Success;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error handling webhook");
|
||||
return WebhookResponse.Success;
|
||||
}
|
||||
}
|
||||
|
||||
public string? GetSubscriptionPlanId(string subscriptionKey)
|
||||
{
|
||||
var planId = _configuration[$"Payment:Subscriptions:Afdian:{subscriptionKey}"];
|
||||
|
||||
if (string.IsNullOrEmpty(planId))
|
||||
{
|
||||
_logger.LogWarning($"Unknown subscription key: {subscriptionKey}");
|
||||
return null;
|
||||
}
|
||||
|
||||
return planId;
|
||||
}
|
||||
}
|
||||
|
||||
public class OrderResponse
|
||||
{
|
||||
[JsonPropertyName("ec")] public int ErrorCode { get; set; }
|
||||
|
||||
[JsonPropertyName("em")] public string ErrorMessage { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("data")] public OrderData Data { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class OrderData
|
||||
{
|
||||
[JsonPropertyName("list")] public List<OrderItem> Orders { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("total_count")] public int TotalCount { get; set; }
|
||||
|
||||
[JsonPropertyName("total_page")] public int TotalPages { get; set; }
|
||||
|
||||
[JsonPropertyName("request")] public RequestDetails Request { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class OrderItem : ISubscriptionOrder
|
||||
{
|
||||
[JsonPropertyName("out_trade_no")] public string TradeNumber { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("user_id")] public string UserId { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("plan_id")] public string PlanId { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("month")] public int Months { get; set; }
|
||||
|
||||
[JsonPropertyName("total_amount")] public string TotalAmount { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("show_amount")] public string ShowAmount { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("status")] public int Status { get; set; }
|
||||
|
||||
[JsonPropertyName("remark")] public string Remark { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("redeem_id")] public string RedeemId { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("product_type")] public int ProductType { get; set; }
|
||||
|
||||
[JsonPropertyName("discount")] public string Discount { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("sku_detail")] public List<object> SkuDetail { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("create_time")] public long CreateTime { get; set; }
|
||||
|
||||
[JsonPropertyName("user_name")] public string UserName { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("plan_title")] public string PlanTitle { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("user_private_id")] public string UserPrivateId { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("address_person")] public string AddressPerson { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("address_phone")] public string AddressPhone { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("address_address")] public string AddressAddress { get; set; } = null!;
|
||||
|
||||
public Instant BegunAt => Instant.FromUnixTimeSeconds(CreateTime);
|
||||
|
||||
public Duration Duration => Duration.FromDays(Months * 30);
|
||||
|
||||
public string Provider => "afdian";
|
||||
|
||||
public string Id => TradeNumber;
|
||||
|
||||
public string SubscriptionId => PlanId;
|
||||
|
||||
public string AccountId => UserId;
|
||||
}
|
||||
|
||||
public class RequestDetails
|
||||
{
|
||||
[JsonPropertyName("user_id")] public string UserId { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("params")] public string Params { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("ts")] public long Timestamp { get; set; }
|
||||
|
||||
[JsonPropertyName("sign")] public string Sign { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request structure for Afdian webhook
|
||||
/// </summary>
|
||||
public class WebhookRequest
|
||||
{
|
||||
[JsonPropertyName("ec")] public int ErrorCode { get; set; }
|
||||
|
||||
[JsonPropertyName("em")] public string ErrorMessage { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("data")] public WebhookOrderData Data { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Order data contained in the webhook
|
||||
/// </summary>
|
||||
public class WebhookOrderData
|
||||
{
|
||||
[JsonPropertyName("type")] public string Type { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("order")] public WebhookOrderDetails Order { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Order details in the webhook
|
||||
/// </summary>
|
||||
public class WebhookOrderDetails : OrderItem
|
||||
{
|
||||
[JsonPropertyName("custom_order_id")] public string CustomOrderId { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response structure to acknowledge webhook receipt
|
||||
/// </summary>
|
||||
public class WebhookResponse
|
||||
{
|
||||
[JsonPropertyName("ec")] public int ErrorCode { get; set; } = 200;
|
||||
|
||||
[JsonPropertyName("em")] public string ErrorMessage { get; set; } = "";
|
||||
|
||||
public static WebhookResponse Success => new()
|
||||
{
|
||||
ErrorCode = 200,
|
||||
ErrorMessage = string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SKU detail item
|
||||
/// </summary>
|
||||
public class SkuDetailItem
|
||||
{
|
||||
[JsonPropertyName("sku_id")] public string SkuId { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("count")] public int Count { get; set; }
|
||||
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("album_id")] public string AlbumId { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("pic")] public string Picture { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Wallet.PaymentHandlers;
|
||||
|
||||
public interface ISubscriptionOrder
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public string SubscriptionId { get; }
|
||||
|
||||
public Instant BegunAt { get; }
|
||||
|
||||
public Duration Duration { get; }
|
||||
|
||||
public string Provider { get; }
|
||||
|
||||
public string AccountId { get; }
|
||||
}
|
||||
297
DysonNetwork.Pass/Wallet/PaymentService.cs
Normal file
297
DysonNetwork.Pass/Wallet/PaymentService.cs
Normal file
@@ -0,0 +1,297 @@
|
||||
using System.Globalization;
|
||||
using DysonNetwork.Pass.Account;
|
||||
using DysonNetwork.Pass.Localization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Wallet;
|
||||
|
||||
public class PaymentService(
|
||||
AppDatabase db,
|
||||
WalletService wat,
|
||||
NotificationService nty,
|
||||
IStringLocalizer<NotificationResource> localizer
|
||||
)
|
||||
{
|
||||
public async Task<Order> CreateOrderAsync(
|
||||
Guid? payeeWalletId,
|
||||
string currency,
|
||||
decimal amount,
|
||||
Duration? expiration = null,
|
||||
string? appIdentifier = null,
|
||||
Dictionary<string, object>? meta = null,
|
||||
bool reuseable = true
|
||||
)
|
||||
{
|
||||
// Check if there's an existing unpaid order that can be reused
|
||||
if (reuseable && appIdentifier != null)
|
||||
{
|
||||
var existingOrder = await db.PaymentOrders
|
||||
.Where(o => o.Status == OrderStatus.Unpaid &&
|
||||
o.PayeeWalletId == payeeWalletId &&
|
||||
o.Currency == currency &&
|
||||
o.Amount == amount &&
|
||||
o.AppIdentifier == appIdentifier &&
|
||||
o.ExpiredAt > SystemClock.Instance.GetCurrentInstant())
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
// If an existing order is found, check if meta matches
|
||||
if (existingOrder != null && meta != null && existingOrder.Meta != null)
|
||||
{
|
||||
// Compare meta dictionaries - if they are equivalent, reuse the order
|
||||
var metaMatches = existingOrder.Meta.Count == meta.Count &&
|
||||
!existingOrder.Meta.Except(meta).Any();
|
||||
|
||||
if (metaMatches)
|
||||
{
|
||||
return existingOrder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new order if no reusable order was found
|
||||
var order = new Order
|
||||
{
|
||||
PayeeWalletId = payeeWalletId,
|
||||
Currency = currency,
|
||||
Amount = amount,
|
||||
ExpiredAt = SystemClock.Instance.GetCurrentInstant().Plus(expiration ?? Duration.FromHours(24)),
|
||||
AppIdentifier = appIdentifier,
|
||||
Meta = meta
|
||||
};
|
||||
|
||||
db.PaymentOrders.Add(order);
|
||||
await db.SaveChangesAsync();
|
||||
return order;
|
||||
}
|
||||
|
||||
public async Task<Transaction> CreateTransactionWithAccountAsync(
|
||||
Guid? payerAccountId,
|
||||
Guid? payeeAccountId,
|
||||
string currency,
|
||||
decimal amount,
|
||||
string? remarks = null,
|
||||
TransactionType type = TransactionType.System
|
||||
)
|
||||
{
|
||||
Wallet? payer = null, payee = null;
|
||||
if (payerAccountId.HasValue)
|
||||
payer = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payerAccountId.Value);
|
||||
if (payeeAccountId.HasValue)
|
||||
payee = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payeeAccountId.Value);
|
||||
|
||||
if (payer == null && payerAccountId.HasValue)
|
||||
throw new ArgumentException("Payer account was specified, but wallet was not found");
|
||||
if (payee == null && payeeAccountId.HasValue)
|
||||
throw new ArgumentException("Payee account was specified, but wallet was not found");
|
||||
|
||||
return await CreateTransactionAsync(
|
||||
payer?.Id,
|
||||
payee?.Id,
|
||||
currency,
|
||||
amount,
|
||||
remarks,
|
||||
type
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<Transaction> CreateTransactionAsync(
|
||||
Guid? payerWalletId,
|
||||
Guid? payeeWalletId,
|
||||
string currency,
|
||||
decimal amount,
|
||||
string? remarks = null,
|
||||
TransactionType type = TransactionType.System
|
||||
)
|
||||
{
|
||||
if (payerWalletId == null && payeeWalletId == null)
|
||||
throw new ArgumentException("At least one wallet must be specified.");
|
||||
if (amount <= 0) throw new ArgumentException("Cannot create transaction with negative or zero amount.");
|
||||
|
||||
var transaction = new Transaction
|
||||
{
|
||||
PayerWalletId = payerWalletId,
|
||||
PayeeWalletId = payeeWalletId,
|
||||
Currency = currency,
|
||||
Amount = amount,
|
||||
Remarks = remarks,
|
||||
Type = type
|
||||
};
|
||||
|
||||
if (payerWalletId.HasValue)
|
||||
{
|
||||
var (payerPocket, isNewlyCreated) =
|
||||
await wat.GetOrCreateWalletPocketAsync(payerWalletId.Value, currency);
|
||||
|
||||
if (isNewlyCreated || payerPocket.Amount < amount)
|
||||
throw new InvalidOperationException("Insufficient funds");
|
||||
|
||||
await db.WalletPockets
|
||||
.Where(p => p.Id == payerPocket.Id && p.Amount >= amount)
|
||||
.ExecuteUpdateAsync(s =>
|
||||
s.SetProperty(p => p.Amount, p => p.Amount - amount));
|
||||
}
|
||||
|
||||
if (payeeWalletId.HasValue)
|
||||
{
|
||||
var (payeePocket, isNewlyCreated) =
|
||||
await wat.GetOrCreateWalletPocketAsync(payeeWalletId.Value, currency, amount);
|
||||
|
||||
if (!isNewlyCreated)
|
||||
await db.WalletPockets
|
||||
.Where(p => p.Id == payeePocket.Id)
|
||||
.ExecuteUpdateAsync(s =>
|
||||
s.SetProperty(p => p.Amount, p => p.Amount + amount));
|
||||
}
|
||||
|
||||
db.PaymentTransactions.Add(transaction);
|
||||
await db.SaveChangesAsync();
|
||||
return transaction;
|
||||
}
|
||||
|
||||
public async Task<Order> PayOrderAsync(Guid orderId, Guid payerWalletId)
|
||||
{
|
||||
var order = await db.PaymentOrders
|
||||
.Include(o => o.Transaction)
|
||||
.FirstOrDefaultAsync(o => o.Id == orderId);
|
||||
|
||||
if (order == null)
|
||||
{
|
||||
throw new InvalidOperationException("Order not found");
|
||||
}
|
||||
|
||||
if (order.Status != OrderStatus.Unpaid)
|
||||
{
|
||||
throw new InvalidOperationException($"Order is in invalid status: {order.Status}");
|
||||
}
|
||||
|
||||
if (order.ExpiredAt < SystemClock.Instance.GetCurrentInstant())
|
||||
{
|
||||
order.Status = OrderStatus.Expired;
|
||||
await db.SaveChangesAsync();
|
||||
throw new InvalidOperationException("Order has expired");
|
||||
}
|
||||
|
||||
var transaction = await CreateTransactionAsync(
|
||||
payerWalletId,
|
||||
order.PayeeWalletId,
|
||||
order.Currency,
|
||||
order.Amount,
|
||||
order.Remarks ?? $"Payment for Order #{order.Id}",
|
||||
type: TransactionType.Order);
|
||||
|
||||
order.TransactionId = transaction.Id;
|
||||
order.Transaction = transaction;
|
||||
order.Status = OrderStatus.Paid;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await NotifyOrderPaid(order);
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
private async Task NotifyOrderPaid(Order order)
|
||||
{
|
||||
if (order.PayeeWallet is null) return;
|
||||
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == order.PayeeWallet.AccountId);
|
||||
if (account is null) return;
|
||||
|
||||
AccountService.SetCultureInfo(account);
|
||||
|
||||
// Due to ID is uuid, it longer than 8 words for sure
|
||||
var readableOrderId = order.Id.ToString().Replace("-", "")[..8];
|
||||
var readableOrderRemark = order.Remarks ?? $"#{readableOrderId}";
|
||||
|
||||
await nty.SendNotification(
|
||||
account,
|
||||
"wallets.orders.paid",
|
||||
localizer["OrderPaidTitle", $"#{readableOrderId}"],
|
||||
null,
|
||||
localizer["OrderPaidBody", order.Amount.ToString(CultureInfo.InvariantCulture), order.Currency,
|
||||
readableOrderRemark],
|
||||
new Dictionary<string, object>()
|
||||
{
|
||||
["order_id"] = order.Id.ToString()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<Order> CancelOrderAsync(Guid orderId)
|
||||
{
|
||||
var order = await db.PaymentOrders.FindAsync(orderId);
|
||||
if (order == null)
|
||||
{
|
||||
throw new InvalidOperationException("Order not found");
|
||||
}
|
||||
|
||||
if (order.Status != OrderStatus.Unpaid)
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot cancel order in status: {order.Status}");
|
||||
}
|
||||
|
||||
order.Status = OrderStatus.Cancelled;
|
||||
await db.SaveChangesAsync();
|
||||
return order;
|
||||
}
|
||||
|
||||
public async Task<(Order Order, Transaction RefundTransaction)> RefundOrderAsync(Guid orderId)
|
||||
{
|
||||
var order = await db.PaymentOrders
|
||||
.Include(o => o.Transaction)
|
||||
.FirstOrDefaultAsync(o => o.Id == orderId);
|
||||
|
||||
if (order == null)
|
||||
{
|
||||
throw new InvalidOperationException("Order not found");
|
||||
}
|
||||
|
||||
if (order.Status != OrderStatus.Paid)
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot refund order in status: {order.Status}");
|
||||
}
|
||||
|
||||
if (order.Transaction == null)
|
||||
{
|
||||
throw new InvalidOperationException("Order has no associated transaction");
|
||||
}
|
||||
|
||||
var refundTransaction = await CreateTransactionAsync(
|
||||
order.PayeeWalletId,
|
||||
order.Transaction.PayerWalletId,
|
||||
order.Currency,
|
||||
order.Amount,
|
||||
$"Refund for order {order.Id}");
|
||||
|
||||
order.Status = OrderStatus.Finished;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return (order, refundTransaction);
|
||||
}
|
||||
|
||||
public async Task<Transaction> TransferAsync(Guid payerAccountId, Guid payeeAccountId, string currency,
|
||||
decimal amount)
|
||||
{
|
||||
var payerWallet = await wat.GetWalletAsync(payerAccountId);
|
||||
if (payerWallet == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Payer wallet not found for account {payerAccountId}");
|
||||
}
|
||||
|
||||
var payeeWallet = await wat.GetWalletAsync(payeeAccountId);
|
||||
if (payeeWallet == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Payee wallet not found for account {payeeAccountId}");
|
||||
}
|
||||
|
||||
return await CreateTransactionAsync(
|
||||
payerWallet.Id,
|
||||
payeeWallet.Id,
|
||||
currency,
|
||||
amount,
|
||||
$"Transfer from account {payerAccountId} to {payeeAccountId}",
|
||||
TransactionType.Transfer);
|
||||
}
|
||||
}
|
||||
233
DysonNetwork.Pass/Wallet/Subscription.cs
Normal file
233
DysonNetwork.Pass/Wallet/Subscription.cs
Normal file
@@ -0,0 +1,233 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Wallet;
|
||||
|
||||
public record class SubscriptionTypeData(
|
||||
string Identifier,
|
||||
string? GroupIdentifier,
|
||||
string Currency,
|
||||
decimal BasePrice,
|
||||
int? RequiredLevel = null
|
||||
)
|
||||
{
|
||||
public static readonly Dictionary<string, SubscriptionTypeData> SubscriptionDict =
|
||||
new()
|
||||
{
|
||||
[SubscriptionType.Twinkle] = new SubscriptionTypeData(
|
||||
SubscriptionType.Twinkle,
|
||||
SubscriptionType.StellarProgram,
|
||||
WalletCurrency.SourcePoint,
|
||||
0,
|
||||
1
|
||||
),
|
||||
[SubscriptionType.Stellar] = new SubscriptionTypeData(
|
||||
SubscriptionType.Stellar,
|
||||
SubscriptionType.StellarProgram,
|
||||
WalletCurrency.SourcePoint,
|
||||
1200,
|
||||
3
|
||||
),
|
||||
[SubscriptionType.Nova] = new SubscriptionTypeData(
|
||||
SubscriptionType.Nova,
|
||||
SubscriptionType.StellarProgram,
|
||||
WalletCurrency.SourcePoint,
|
||||
2400,
|
||||
6
|
||||
),
|
||||
[SubscriptionType.Supernova] = new SubscriptionTypeData(
|
||||
SubscriptionType.Supernova,
|
||||
SubscriptionType.StellarProgram,
|
||||
WalletCurrency.SourcePoint,
|
||||
3600,
|
||||
9
|
||||
)
|
||||
};
|
||||
|
||||
public static readonly Dictionary<string, string> SubscriptionHumanReadable =
|
||||
new()
|
||||
{
|
||||
[SubscriptionType.Twinkle] = "Stellar Program Twinkle",
|
||||
[SubscriptionType.Stellar] = "Stellar Program",
|
||||
[SubscriptionType.Nova] = "Stellar Program Nova",
|
||||
[SubscriptionType.Supernova] = "Stellar Program Supernova"
|
||||
};
|
||||
}
|
||||
|
||||
public abstract class SubscriptionType
|
||||
{
|
||||
/// <summary>
|
||||
/// DO NOT USE THIS TYPE DIRECTLY,
|
||||
/// this is the prefix of all the stellar program subscriptions.
|
||||
/// </summary>
|
||||
public const string StellarProgram = "solian.stellar";
|
||||
|
||||
/// <summary>
|
||||
/// No actual usage, just tells there is a free level named twinkle.
|
||||
/// Applies to every registered user by default, so there is no need to create a record in db for that.
|
||||
/// </summary>
|
||||
public const string Twinkle = "solian.stellar.twinkle";
|
||||
|
||||
public const string Stellar = "solian.stellar.primary";
|
||||
public const string Nova = "solian.stellar.nova";
|
||||
public const string Supernova = "solian.stellar.supernova";
|
||||
}
|
||||
|
||||
public abstract class SubscriptionPaymentMethod
|
||||
{
|
||||
/// <summary>
|
||||
/// The solar points / solar dollars.
|
||||
/// </summary>
|
||||
public const string InAppWallet = "solian.wallet";
|
||||
|
||||
/// <summary>
|
||||
/// afdian.com
|
||||
/// aka. China patreon
|
||||
/// </summary>
|
||||
public const string Afdian = "afdian";
|
||||
}
|
||||
|
||||
public enum SubscriptionStatus
|
||||
{
|
||||
Unpaid,
|
||||
Active,
|
||||
Expired,
|
||||
Cancelled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The subscription is for the Stellar Program in most cases.
|
||||
/// The paid subscription in another word.
|
||||
/// </summary>
|
||||
[Index(nameof(Identifier))]
|
||||
public class Subscription : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Instant BegunAt { get; set; }
|
||||
public Instant? EndedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The type of the subscriptions
|
||||
/// </summary>
|
||||
[MaxLength(4096)]
|
||||
public string Identifier { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The field is used to override the activation status of the membership.
|
||||
/// Might be used for refund handling and other special cases.
|
||||
///
|
||||
/// Go see the IsAvailable field if you want to get real the status of the membership.
|
||||
/// </summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates is the current user got the membership for free,
|
||||
/// to prevent giving the same discount for the same user again.
|
||||
/// </summary>
|
||||
public bool IsFreeTrial { get; set; }
|
||||
|
||||
public SubscriptionStatus Status { get; set; } = SubscriptionStatus.Unpaid;
|
||||
|
||||
[MaxLength(4096)] public string PaymentMethod { get; set; } = null!;
|
||||
[Column(TypeName = "jsonb")] public PaymentDetails PaymentDetails { get; set; } = null!;
|
||||
public decimal BasePrice { get; set; }
|
||||
public Guid? CouponId { get; set; }
|
||||
public Coupon? Coupon { get; set; }
|
||||
public Instant? RenewalAt { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
public Account.Account Account { get; set; } = null!;
|
||||
|
||||
[NotMapped]
|
||||
public bool IsAvailable
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!IsActive) return false;
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
if (BegunAt > now) return false;
|
||||
if (EndedAt.HasValue && now > EndedAt.Value) return false;
|
||||
if (RenewalAt.HasValue && now > RenewalAt.Value) return false;
|
||||
if (Status != SubscriptionStatus.Active) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
[NotMapped]
|
||||
public decimal FinalPrice
|
||||
{
|
||||
get
|
||||
{
|
||||
if (IsFreeTrial) return 0;
|
||||
if (Coupon == null) return BasePrice;
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
if (Coupon.AffectedAt.HasValue && now < Coupon.AffectedAt.Value ||
|
||||
Coupon.ExpiredAt.HasValue && now > Coupon.ExpiredAt.Value) return BasePrice;
|
||||
|
||||
if (Coupon.DiscountAmount.HasValue) return BasePrice - Coupon.DiscountAmount.Value;
|
||||
if (Coupon.DiscountRate.HasValue) return BasePrice * (decimal)(1 - Coupon.DiscountRate.Value);
|
||||
return BasePrice;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class PaymentDetails
|
||||
{
|
||||
public string Currency { get; set; } = null!;
|
||||
public string? OrderId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A discount that can applies in purchases among the Solar Network.
|
||||
/// For now, it can be used in the subscription purchase.
|
||||
/// </summary>
|
||||
public class Coupon : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// The items that can apply this coupon.
|
||||
/// Leave it to null to apply to all items.
|
||||
/// </summary>
|
||||
[MaxLength(4096)]
|
||||
public string? Identifier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The code that human-readable and memorizable.
|
||||
/// Leave it blank to use it only with the ID.
|
||||
/// </summary>
|
||||
[MaxLength(1024)]
|
||||
public string? Code { get; set; }
|
||||
|
||||
public Instant? AffectedAt { get; set; }
|
||||
public Instant? ExpiredAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The amount of the discount.
|
||||
/// If this field and the rate field are both not null,
|
||||
/// the amount discount will be applied and the discount rate will be ignored.
|
||||
/// Formula: <code>final price = base price - discount amount</code>
|
||||
/// </summary>
|
||||
public decimal? DiscountAmount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The percentage of the discount.
|
||||
/// If this field and the amount field are both not null,
|
||||
/// this field will be ignored.
|
||||
/// Formula: <code>final price = base price * (1 - discount rate)</code>
|
||||
/// </summary>
|
||||
public double? DiscountRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The max usage of the current coupon.
|
||||
/// Leave it to null to use it unlimited.
|
||||
/// </summary>
|
||||
public int? MaxUsage { get; set; }
|
||||
}
|
||||
204
DysonNetwork.Pass/Wallet/SubscriptionController.cs
Normal file
204
DysonNetwork.Pass/Wallet/SubscriptionController.cs
Normal file
@@ -0,0 +1,204 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Pass.Wallet.PaymentHandlers;
|
||||
|
||||
namespace DysonNetwork.Pass.Wallet;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/subscriptions")]
|
||||
public class SubscriptionController(SubscriptionService subscriptions, AfdianPaymentHandler afdian, AppDatabase db) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<Subscription>>> ListSubscriptions(
|
||||
[FromQuery] int offset = 0,
|
||||
[FromQuery] int take = 20
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var query = db.WalletSubscriptions.AsQueryable()
|
||||
.Where(s => s.AccountId == currentUser.Id)
|
||||
.Include(s => s.Coupon)
|
||||
.OrderByDescending(s => s.BegunAt);
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
|
||||
var subscriptionsList = await query
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
|
||||
Response.Headers["X-Total"] = totalCount.ToString();
|
||||
|
||||
return subscriptionsList;
|
||||
}
|
||||
|
||||
[HttpGet("fuzzy/{prefix}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Subscription>> GetSubscriptionFuzzy(string prefix)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var subs = await db.WalletSubscriptions
|
||||
.Where(s => s.AccountId == currentUser.Id && s.IsActive)
|
||||
.Where(s => EF.Functions.ILike(s.Identifier, prefix + "%"))
|
||||
.OrderByDescending(s => s.BegunAt)
|
||||
.ToListAsync();
|
||||
if (subs.Count == 0) return NotFound();
|
||||
var subscription = subs.FirstOrDefault(s => s.IsAvailable);
|
||||
if (subscription is null) return NotFound();
|
||||
|
||||
return Ok(subscription);
|
||||
}
|
||||
|
||||
[HttpGet("{identifier}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Subscription>> GetSubscription(string identifier)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var subscription = await subscriptions.GetSubscriptionAsync(currentUser.Id, identifier);
|
||||
if (subscription is null) return NotFound($"Subscription with identifier {identifier} was not found.");
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
public class CreateSubscriptionRequest
|
||||
{
|
||||
[Required] public string Identifier { get; set; } = null!;
|
||||
[Required] public string PaymentMethod { get; set; } = null!;
|
||||
[Required] public PaymentDetails PaymentDetails { get; set; } = null!;
|
||||
public string? Coupon { get; set; }
|
||||
public int? CycleDurationDays { get; set; }
|
||||
public bool IsFreeTrial { get; set; } = false;
|
||||
public bool IsAutoRenewal { get; set; } = true;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Subscription>> CreateSubscription(
|
||||
[FromBody] CreateSubscriptionRequest request,
|
||||
[FromHeader(Name = "X-Noop")] bool noop = false
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
Duration? cycleDuration = null;
|
||||
if (request.CycleDurationDays.HasValue)
|
||||
cycleDuration = Duration.FromDays(request.CycleDurationDays.Value);
|
||||
|
||||
try
|
||||
{
|
||||
var subscription = await subscriptions.CreateSubscriptionAsync(
|
||||
currentUser,
|
||||
request.Identifier,
|
||||
request.PaymentMethod,
|
||||
request.PaymentDetails,
|
||||
cycleDuration,
|
||||
request.Coupon,
|
||||
request.IsFreeTrial,
|
||||
request.IsAutoRenewal,
|
||||
noop
|
||||
);
|
||||
|
||||
return subscription;
|
||||
}
|
||||
catch (ArgumentOutOfRangeException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{identifier}/cancel")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Subscription>> CancelSubscription(string identifier)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
var subscription = await subscriptions.CancelSubscriptionAsync(currentUser.Id, identifier);
|
||||
return subscription;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{identifier}/order")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Order>> CreateSubscriptionOrder(string identifier)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
var order = await subscriptions.CreateSubscriptionOrder(currentUser.Id, identifier);
|
||||
return order;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public class SubscriptionOrderRequest
|
||||
{
|
||||
[Required] public Guid OrderId { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("order/handle")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Subscription>> HandleSubscriptionOrder([FromBody] SubscriptionOrderRequest request)
|
||||
{
|
||||
var order = await db.PaymentOrders.FindAsync(request.OrderId);
|
||||
if (order is null) return NotFound($"Order with ID {request.OrderId} was not found.");
|
||||
|
||||
try
|
||||
{
|
||||
var subscription = await subscriptions.HandleSubscriptionOrder(order);
|
||||
return subscription;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public class RestorePurchaseRequest
|
||||
{
|
||||
[Required] public string OrderId { get; set; } = null!;
|
||||
}
|
||||
|
||||
[HttpPost("order/restore/afdian")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> RestorePurchaseFromAfdian([FromBody] RestorePurchaseRequest request)
|
||||
{
|
||||
var order = await afdian.GetOrderAsync(request.OrderId);
|
||||
if (order is null) return NotFound($"Order with ID {request.OrderId} was not found.");
|
||||
|
||||
var subscription = await subscriptions.CreateSubscriptionFromOrder(order);
|
||||
return Ok(subscription);
|
||||
}
|
||||
|
||||
[HttpPost("order/handle/afdian")]
|
||||
public async Task<ActionResult<WebhookResponse>> AfdianWebhook()
|
||||
{
|
||||
var response = await afdian.HandleWebhook(Request, async webhookData =>
|
||||
{
|
||||
var order = webhookData.Order;
|
||||
await subscriptions.CreateSubscriptionFromOrder(order);
|
||||
});
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
137
DysonNetwork.Pass/Wallet/SubscriptionRenewalJob.cs
Normal file
137
DysonNetwork.Pass/Wallet/SubscriptionRenewalJob.cs
Normal file
@@ -0,0 +1,137 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using Quartz;
|
||||
|
||||
namespace DysonNetwork.Pass.Wallet;
|
||||
|
||||
public class SubscriptionRenewalJob(
|
||||
AppDatabase db,
|
||||
SubscriptionService subscriptionService,
|
||||
PaymentService paymentService,
|
||||
WalletService walletService,
|
||||
ILogger<SubscriptionRenewalJob> logger
|
||||
) : IJob
|
||||
{
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
logger.LogInformation("Starting subscription auto-renewal job...");
|
||||
|
||||
// First update expired subscriptions
|
||||
var expiredCount = await subscriptionService.UpdateExpiredSubscriptionsAsync();
|
||||
logger.LogInformation("Updated {ExpiredCount} expired subscriptions", expiredCount);
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
const int batchSize = 100; // Process in smaller batches
|
||||
var processedCount = 0;
|
||||
var renewedCount = 0;
|
||||
var failedCount = 0;
|
||||
|
||||
// Find subscriptions that need renewal (due for renewal and are still active)
|
||||
var subscriptionsToRenew = await db.WalletSubscriptions
|
||||
.Where(s => s.RenewalAt.HasValue && s.RenewalAt.Value <= now) // Due for renewal
|
||||
.Where(s => s.Status == SubscriptionStatus.Active) // Only paid subscriptions
|
||||
.Where(s => s.IsActive) // Only active subscriptions
|
||||
.Where(s => !s.IsFreeTrial) // Exclude free trials
|
||||
.OrderBy(s => s.RenewalAt) // Process oldest first
|
||||
.Take(batchSize)
|
||||
.Include(s => s.Coupon) // Include coupon information
|
||||
.ToListAsync();
|
||||
|
||||
var totalSubscriptions = subscriptionsToRenew.Count;
|
||||
logger.LogInformation("Found {TotalSubscriptions} subscriptions due for renewal", totalSubscriptions);
|
||||
|
||||
foreach (var subscription in subscriptionsToRenew)
|
||||
{
|
||||
try
|
||||
{
|
||||
processedCount++;
|
||||
logger.LogDebug(
|
||||
"Processing renewal for subscription {SubscriptionId} (Identifier: {Identifier}) for account {AccountId}",
|
||||
subscription.Id, subscription.Identifier, subscription.AccountId);
|
||||
|
||||
if (subscription.RenewalAt is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Subscription {SubscriptionId} (Identifier: {Identifier}) has no renewal date or has been cancelled.",
|
||||
subscription.Id, subscription.Identifier);
|
||||
subscription.Status = SubscriptionStatus.Cancelled;
|
||||
db.WalletSubscriptions.Update(subscription);
|
||||
await db.SaveChangesAsync();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate next cycle duration based on current cycle
|
||||
var currentCycle = subscription.EndedAt!.Value - subscription.BegunAt;
|
||||
|
||||
// Create an order for the renewal payment
|
||||
var order = await paymentService.CreateOrderAsync(
|
||||
null,
|
||||
WalletCurrency.GoldenPoint,
|
||||
subscription.FinalPrice,
|
||||
appIdentifier: SubscriptionService.SubscriptionOrderIdentifier,
|
||||
meta: new Dictionary<string, object>()
|
||||
{
|
||||
["subscription_id"] = subscription.Id.ToString(),
|
||||
["subscription_identifier"] = subscription.Identifier,
|
||||
["is_renewal"] = true
|
||||
}
|
||||
);
|
||||
|
||||
// Try to process the payment automatically
|
||||
if (subscription.PaymentMethod == SubscriptionPaymentMethod.InAppWallet)
|
||||
{
|
||||
try
|
||||
{
|
||||
var wallet = await walletService.GetWalletAsync(subscription.AccountId);
|
||||
if (wallet is null) continue;
|
||||
|
||||
// Process automatic payment from wallet
|
||||
await paymentService.PayOrderAsync(order.Id, wallet.Id);
|
||||
|
||||
// Update subscription details
|
||||
subscription.BegunAt = subscription.EndedAt!.Value;
|
||||
subscription.EndedAt = subscription.BegunAt.Plus(currentCycle);
|
||||
subscription.RenewalAt = subscription.EndedAt;
|
||||
|
||||
db.WalletSubscriptions.Update(subscription);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
renewedCount++;
|
||||
logger.LogInformation("Successfully renewed subscription {SubscriptionId}", subscription.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// If auto-payment fails, mark for manual payment
|
||||
logger.LogWarning(ex, "Failed to auto-renew subscription {SubscriptionId} with wallet payment",
|
||||
subscription.Id);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// For other payment methods, mark as pending payment
|
||||
logger.LogInformation("Subscription {SubscriptionId} requires manual payment via {PaymentMethod}",
|
||||
subscription.Id, subscription.PaymentMethod);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error processing subscription {SubscriptionId}", subscription.Id);
|
||||
failedCount++;
|
||||
}
|
||||
|
||||
// Log progress periodically
|
||||
if (processedCount % 20 == 0 || processedCount == totalSubscriptions)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Progress: processed {ProcessedCount}/{TotalSubscriptions} subscriptions, {RenewedCount} renewed, {FailedCount} failed",
|
||||
processedCount, totalSubscriptions, renewedCount, failedCount);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Completed subscription renewal job. Processed: {ProcessedCount}, Renewed: {RenewedCount}, Failed: {FailedCount}",
|
||||
processedCount, renewedCount, failedCount);
|
||||
}
|
||||
}
|
||||
394
DysonNetwork.Pass/Wallet/SubscriptionService.cs
Normal file
394
DysonNetwork.Pass/Wallet/SubscriptionService.cs
Normal file
@@ -0,0 +1,394 @@
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Pass.Account;
|
||||
using DysonNetwork.Pass.Localization;
|
||||
using DysonNetwork.Pass.Wallet.PaymentHandlers;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Wallet;
|
||||
|
||||
public class SubscriptionService(
|
||||
AppDatabase db,
|
||||
PaymentService payment,
|
||||
AccountService accounts,
|
||||
NotificationService nty,
|
||||
IStringLocalizer<NotificationResource> localizer,
|
||||
IConfiguration configuration,
|
||||
ICacheService cache,
|
||||
ILogger<SubscriptionService> logger
|
||||
)
|
||||
{
|
||||
public async Task<Subscription> CreateSubscriptionAsync(
|
||||
Account.Account account,
|
||||
string identifier,
|
||||
string paymentMethod,
|
||||
PaymentDetails paymentDetails,
|
||||
Duration? cycleDuration = null,
|
||||
string? coupon = null,
|
||||
bool isFreeTrial = false,
|
||||
bool isAutoRenewal = true,
|
||||
bool noop = false
|
||||
)
|
||||
{
|
||||
var subscriptionInfo = SubscriptionTypeData
|
||||
.SubscriptionDict.TryGetValue(identifier, out var template)
|
||||
? template
|
||||
: null;
|
||||
if (subscriptionInfo is null)
|
||||
throw new ArgumentOutOfRangeException(nameof(identifier), $@"Subscription {identifier} was not found.");
|
||||
var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null
|
||||
? SubscriptionTypeData.SubscriptionDict
|
||||
.Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier)
|
||||
.Select(s => s.Value.Identifier)
|
||||
.ToArray()
|
||||
: [identifier];
|
||||
|
||||
cycleDuration ??= Duration.FromDays(30);
|
||||
|
||||
var existingSubscription = await GetSubscriptionAsync(account.Id, subscriptionsInGroup);
|
||||
if (existingSubscription is not null && !noop)
|
||||
throw new InvalidOperationException($"Active subscription with identifier {identifier} already exists.");
|
||||
if (existingSubscription is not null)
|
||||
return existingSubscription;
|
||||
|
||||
if (subscriptionInfo.RequiredLevel > 0)
|
||||
{
|
||||
var profile = await db.AccountProfiles
|
||||
.Where(p => p.AccountId == account.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (profile is null) throw new InvalidOperationException("Account profile was not found.");
|
||||
if (profile.Level < subscriptionInfo.RequiredLevel)
|
||||
throw new InvalidOperationException(
|
||||
$"Account level must be at least {subscriptionInfo.RequiredLevel} to subscribe to {identifier}."
|
||||
);
|
||||
}
|
||||
|
||||
if (isFreeTrial)
|
||||
{
|
||||
var prevFreeTrial = await db.WalletSubscriptions
|
||||
.Where(s => s.AccountId == account.Id && s.Identifier == identifier && s.IsFreeTrial)
|
||||
.FirstOrDefaultAsync();
|
||||
if (prevFreeTrial is not null)
|
||||
throw new InvalidOperationException("Free trial already exists.");
|
||||
}
|
||||
|
||||
Coupon? couponData = null;
|
||||
if (coupon is not null)
|
||||
{
|
||||
var inputCouponId = Guid.TryParse(coupon, out var parsedCouponId) ? parsedCouponId : Guid.Empty;
|
||||
couponData = await db.WalletCoupons
|
||||
.Where(c => (c.Id == inputCouponId) || (c.Identifier != null && c.Identifier == coupon))
|
||||
.FirstOrDefaultAsync();
|
||||
if (couponData is null) throw new InvalidOperationException($"Coupon {coupon} was not found.");
|
||||
}
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var subscription = new Subscription
|
||||
{
|
||||
BegunAt = now,
|
||||
EndedAt = now.Plus(cycleDuration.Value),
|
||||
Identifier = identifier,
|
||||
IsActive = true,
|
||||
IsFreeTrial = isFreeTrial,
|
||||
Status = SubscriptionStatus.Unpaid,
|
||||
PaymentMethod = paymentMethod,
|
||||
PaymentDetails = paymentDetails,
|
||||
BasePrice = subscriptionInfo.BasePrice,
|
||||
CouponId = couponData?.Id,
|
||||
Coupon = couponData,
|
||||
RenewalAt = (isFreeTrial || !isAutoRenewal) ? null : now.Plus(cycleDuration.Value),
|
||||
AccountId = account.Id,
|
||||
};
|
||||
|
||||
db.WalletSubscriptions.Add(subscription);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
public async Task<Subscription> CreateSubscriptionFromOrder(ISubscriptionOrder order)
|
||||
{
|
||||
var cfgSection = configuration.GetSection("Payment:Subscriptions");
|
||||
var provider = order.Provider;
|
||||
|
||||
var currency = "irl";
|
||||
var subscriptionIdentifier = order.SubscriptionId;
|
||||
switch (provider)
|
||||
{
|
||||
case "afdian":
|
||||
// Get the Afdian section first, then bind it to a dictionary
|
||||
var afdianPlans = cfgSection.GetSection("Afdian").Get<Dictionary<string, string>>();
|
||||
logger.LogInformation("Afdian plans configuration: {Plans}", JsonSerializer.Serialize(afdianPlans));
|
||||
if (afdianPlans != null && afdianPlans.TryGetValue(subscriptionIdentifier, out var planName))
|
||||
subscriptionIdentifier = planName;
|
||||
currency = "cny";
|
||||
break;
|
||||
}
|
||||
|
||||
var subscriptionTemplate = SubscriptionTypeData
|
||||
.SubscriptionDict.TryGetValue(subscriptionIdentifier, out var template)
|
||||
? template
|
||||
: null;
|
||||
if (subscriptionTemplate is null)
|
||||
throw new ArgumentOutOfRangeException(nameof(subscriptionIdentifier),
|
||||
$@"Subscription {subscriptionIdentifier} was not found.");
|
||||
|
||||
Account.Account? account = null;
|
||||
if (!string.IsNullOrEmpty(provider))
|
||||
account = await accounts.LookupAccountByConnection(order.AccountId, provider);
|
||||
else if (Guid.TryParse(order.AccountId, out var accountId))
|
||||
account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == accountId);
|
||||
|
||||
if (account is null)
|
||||
throw new InvalidOperationException($"Account was not found with identifier {order.AccountId}");
|
||||
|
||||
var cycleDuration = order.Duration;
|
||||
|
||||
var existingSubscription = await GetSubscriptionAsync(account.Id, subscriptionIdentifier);
|
||||
if (existingSubscription is not null && existingSubscription.PaymentMethod != provider)
|
||||
throw new InvalidOperationException(
|
||||
$"Active subscription with identifier {subscriptionIdentifier} already exists.");
|
||||
if (existingSubscription?.PaymentDetails.OrderId == order.Id)
|
||||
return existingSubscription;
|
||||
if (existingSubscription is not null)
|
||||
{
|
||||
// Same provider, but different order, renew the subscription
|
||||
existingSubscription.PaymentDetails.OrderId = order.Id;
|
||||
existingSubscription.EndedAt = order.BegunAt.Plus(cycleDuration);
|
||||
existingSubscription.RenewalAt = order.BegunAt.Plus(cycleDuration);
|
||||
existingSubscription.Status = SubscriptionStatus.Active;
|
||||
|
||||
db.Update(existingSubscription);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return existingSubscription;
|
||||
}
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
BegunAt = order.BegunAt,
|
||||
EndedAt = order.BegunAt.Plus(cycleDuration),
|
||||
IsActive = true,
|
||||
Status = SubscriptionStatus.Active,
|
||||
Identifier = subscriptionIdentifier,
|
||||
PaymentMethod = provider,
|
||||
PaymentDetails = new PaymentDetails
|
||||
{
|
||||
Currency = currency,
|
||||
OrderId = order.Id,
|
||||
},
|
||||
BasePrice = subscriptionTemplate.BasePrice,
|
||||
RenewalAt = order.BegunAt.Plus(cycleDuration),
|
||||
AccountId = account.Id,
|
||||
};
|
||||
|
||||
db.WalletSubscriptions.Add(subscription);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await NotifySubscriptionBegun(subscription);
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancel the renewal of the current activated subscription.
|
||||
/// </summary>
|
||||
/// <param name="accountId">The user who requested the action.</param>
|
||||
/// <param name="identifier">The subscription identifier</param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="InvalidOperationException">The active subscription was not found</exception>
|
||||
public async Task<Subscription> CancelSubscriptionAsync(Guid accountId, string identifier)
|
||||
{
|
||||
var subscription = await GetSubscriptionAsync(accountId, identifier);
|
||||
if (subscription is null)
|
||||
throw new InvalidOperationException($"Subscription with identifier {identifier} was not found.");
|
||||
if (subscription.Status != SubscriptionStatus.Active)
|
||||
throw new InvalidOperationException("Subscription is already cancelled.");
|
||||
if (subscription.RenewalAt is null)
|
||||
throw new InvalidOperationException("Subscription is no need to be cancelled.");
|
||||
if (subscription.PaymentMethod != SubscriptionPaymentMethod.InAppWallet)
|
||||
throw new InvalidOperationException(
|
||||
"Only in-app wallet subscription can be cancelled. For other payment methods, please head to the payment provider."
|
||||
);
|
||||
|
||||
subscription.RenewalAt = null;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Invalidate the cache for this subscription
|
||||
var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{identifier}";
|
||||
await cache.RemoveAsync(cacheKey);
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
public const string SubscriptionOrderIdentifier = "solian.subscription.order";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a subscription order for an unpaid or expired subscription.
|
||||
/// If the subscription is active, it will extend its expiration date.
|
||||
/// </summary>
|
||||
/// <param name="accountId">The unique identifier for the account associated with the subscription.</param>
|
||||
/// <param name="identifier">The unique subscription identifier.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains the created subscription order.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when no matching unpaid or expired subscription is found.</exception>
|
||||
public async Task<Order> CreateSubscriptionOrder(Guid accountId, string identifier)
|
||||
{
|
||||
var subscription = await db.WalletSubscriptions
|
||||
.Where(s => s.AccountId == accountId && s.Identifier == identifier)
|
||||
.Where(s => s.Status != SubscriptionStatus.Expired)
|
||||
.Include(s => s.Coupon)
|
||||
.OrderByDescending(s => s.BegunAt)
|
||||
.FirstOrDefaultAsync();
|
||||
if (subscription is null) throw new InvalidOperationException("No matching subscription found.");
|
||||
|
||||
var subscriptionInfo = SubscriptionTypeData.SubscriptionDict
|
||||
.TryGetValue(subscription.Identifier, out var template)
|
||||
? template
|
||||
: null;
|
||||
if (subscriptionInfo is null) throw new InvalidOperationException("No matching subscription found.");
|
||||
|
||||
return await payment.CreateOrderAsync(
|
||||
null,
|
||||
subscriptionInfo.Currency,
|
||||
subscription.FinalPrice,
|
||||
appIdentifier: SubscriptionOrderIdentifier,
|
||||
meta: new Dictionary<string, object>()
|
||||
{
|
||||
["subscription_id"] = subscription.Id.ToString(),
|
||||
["subscription_identifier"] = subscription.Identifier,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<Subscription> HandleSubscriptionOrder(Order order)
|
||||
{
|
||||
if (order.AppIdentifier != SubscriptionOrderIdentifier || order.Status != OrderStatus.Paid ||
|
||||
order.Meta?["subscription_id"] is not JsonElement subscriptionIdJson)
|
||||
throw new InvalidOperationException("Invalid order.");
|
||||
|
||||
var subscriptionId = Guid.TryParse(subscriptionIdJson.ToString(), out var parsedSubscriptionId)
|
||||
? parsedSubscriptionId
|
||||
: Guid.Empty;
|
||||
if (subscriptionId == Guid.Empty)
|
||||
throw new InvalidOperationException("Invalid order.");
|
||||
var subscription = await db.WalletSubscriptions
|
||||
.Where(s => s.Id == subscriptionId)
|
||||
.Include(s => s.Coupon)
|
||||
.FirstOrDefaultAsync();
|
||||
if (subscription is null)
|
||||
throw new InvalidOperationException("Invalid order.");
|
||||
|
||||
if (subscription.Status == SubscriptionStatus.Expired)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var cycle = subscription.BegunAt.Minus(subscription.RenewalAt ?? subscription.EndedAt ?? now);
|
||||
|
||||
var nextRenewalAt = subscription.RenewalAt?.Plus(cycle);
|
||||
var nextEndedAt = subscription.EndedAt?.Plus(cycle);
|
||||
|
||||
subscription.RenewalAt = nextRenewalAt;
|
||||
subscription.EndedAt = nextEndedAt;
|
||||
}
|
||||
|
||||
subscription.Status = SubscriptionStatus.Active;
|
||||
|
||||
db.Update(subscription);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await NotifySubscriptionBegun(subscription);
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the status of expired subscriptions to reflect their current state.
|
||||
/// This helps maintain accurate subscription records and is typically called periodically.
|
||||
/// </summary>
|
||||
/// <param name="batchSize">Maximum number of subscriptions to process</param>
|
||||
/// <returns>Number of subscriptions that were marked as expired</returns>
|
||||
public async Task<int> UpdateExpiredSubscriptionsAsync(int batchSize = 100)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
// Find active subscriptions that have passed their end date
|
||||
var expiredSubscriptions = await db.WalletSubscriptions
|
||||
.Where(s => s.IsActive)
|
||||
.Where(s => s.Status == SubscriptionStatus.Active)
|
||||
.Where(s => s.EndedAt.HasValue && s.EndedAt.Value < now)
|
||||
.Take(batchSize)
|
||||
.ToListAsync();
|
||||
|
||||
if (expiredSubscriptions.Count == 0)
|
||||
return 0;
|
||||
|
||||
foreach (var subscription in expiredSubscriptions)
|
||||
{
|
||||
subscription.Status = SubscriptionStatus.Expired;
|
||||
|
||||
// Clear the cache for this subscription
|
||||
var cacheKey = $"{SubscriptionCacheKeyPrefix}{subscription.AccountId}:{subscription.Identifier}";
|
||||
await cache.RemoveAsync(cacheKey);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return expiredSubscriptions.Count;
|
||||
}
|
||||
|
||||
private async Task NotifySubscriptionBegun(Subscription subscription)
|
||||
{
|
||||
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == subscription.AccountId);
|
||||
if (account is null) return;
|
||||
|
||||
AccountService.SetCultureInfo(account);
|
||||
|
||||
var humanReadableName =
|
||||
SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(subscription.Identifier, out var humanReadable)
|
||||
? humanReadable
|
||||
: subscription.Identifier;
|
||||
var duration = subscription.EndedAt is not null
|
||||
? subscription.EndedAt.Value.Minus(subscription.BegunAt).Days.ToString()
|
||||
: "infinite";
|
||||
|
||||
await nty.SendNotification(
|
||||
account,
|
||||
"subscriptions.begun",
|
||||
localizer["SubscriptionAppliedTitle", humanReadableName],
|
||||
null,
|
||||
localizer["SubscriptionAppliedBody", duration, humanReadableName],
|
||||
new Dictionary<string, object>()
|
||||
{
|
||||
["subscription_id"] = subscription.Id.ToString(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private const string SubscriptionCacheKeyPrefix = "subscription:";
|
||||
|
||||
public async Task<Subscription?> GetSubscriptionAsync(Guid accountId, params string[] identifiers)
|
||||
{
|
||||
// Create a unique cache key for this subscription
|
||||
var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{string.Join(",", identifiers)}";
|
||||
|
||||
// Try to get the subscription from cache first
|
||||
var (found, cachedSubscription) = await cache.GetAsyncWithStatus<Subscription>(cacheKey);
|
||||
if (found && cachedSubscription != null)
|
||||
{
|
||||
return cachedSubscription;
|
||||
}
|
||||
|
||||
// If not in cache, get from database
|
||||
var subscription = await db.WalletSubscriptions
|
||||
.Where(s => s.AccountId == accountId && identifiers.Contains(s.Identifier))
|
||||
.OrderByDescending(s => s.BegunAt)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
// Cache the result if found (with 30 minutes expiry)
|
||||
if (subscription != null)
|
||||
await cache.SetAsync(cacheKey, subscription, TimeSpan.FromMinutes(30));
|
||||
|
||||
return subscription;
|
||||
}
|
||||
}
|
||||
25
DysonNetwork.Pass/Wallet/Wallet.cs
Normal file
25
DysonNetwork.Pass/Wallet/Wallet.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Shared.Data;
|
||||
|
||||
namespace DysonNetwork.Pass.Wallet;
|
||||
|
||||
public class Wallet : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public ICollection<WalletPocket> Pockets { get; set; } = new List<WalletPocket>();
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
public Account.Account Account { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class WalletPocket : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(128)] public string Currency { get; set; } = null!;
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public Guid WalletId { get; set; }
|
||||
[JsonIgnore] public Wallet Wallet { get; set; } = null!;
|
||||
}
|
||||
101
DysonNetwork.Pass/Wallet/WalletController.cs
Normal file
101
DysonNetwork.Pass/Wallet/WalletController.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Pass.Permission;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Pass.Wallet;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/wallets")]
|
||||
public class WalletController(AppDatabase db, WalletService ws, PaymentService payment) : ControllerBase
|
||||
{
|
||||
[HttpPost]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Wallet>> CreateWallet()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
var wallet = await ws.CreateWalletAsync(currentUser.Id);
|
||||
return Ok(wallet);
|
||||
}
|
||||
catch (Exception err)
|
||||
{
|
||||
return BadRequest(err.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Wallet>> GetWallet()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var wallet = await ws.GetWalletAsync(currentUser.Id);
|
||||
if (wallet is null) return NotFound("Wallet was not found, please create one first.");
|
||||
return Ok(wallet);
|
||||
}
|
||||
|
||||
[HttpGet("transactions")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<Transaction>>> GetTransactions(
|
||||
[FromQuery] int offset = 0, [FromQuery] int take = 20
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var query = db.PaymentTransactions.AsQueryable()
|
||||
.Include(t => t.PayeeWallet)
|
||||
.Include(t => t.PayerWallet)
|
||||
.Where(t => (t.PayeeWallet != null && t.PayeeWallet.AccountId == currentUser.Id) ||
|
||||
(t.PayerWallet != null && t.PayerWallet.AccountId == currentUser.Id));
|
||||
|
||||
var transactionCount = await query.CountAsync();
|
||||
var transactions = await query
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
Response.Headers["X-Total"] = transactionCount.ToString();
|
||||
|
||||
return Ok(transactions);
|
||||
}
|
||||
|
||||
public class WalletBalanceRequest
|
||||
{
|
||||
public string? Remark { get; set; }
|
||||
[Required] public decimal Amount { get; set; }
|
||||
[Required] public string Currency { get; set; } = null!;
|
||||
[Required] public Guid AccountId { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("balance")]
|
||||
[Authorize]
|
||||
[RequiredPermission("maintenance", "wallets.balance.modify")]
|
||||
public async Task<ActionResult<Transaction>> ModifyWalletBalance([FromBody] WalletBalanceRequest request)
|
||||
{
|
||||
var wallet = await ws.GetWalletAsync(request.AccountId);
|
||||
if (wallet is null) return NotFound("Wallet was not found.");
|
||||
|
||||
var transaction = request.Amount >= 0
|
||||
? await payment.CreateTransactionAsync(
|
||||
payerWalletId: null,
|
||||
payeeWalletId: wallet.Id,
|
||||
currency: request.Currency,
|
||||
amount: request.Amount,
|
||||
remarks: request.Remark
|
||||
)
|
||||
: await payment.CreateTransactionAsync(
|
||||
payerWalletId: wallet.Id,
|
||||
payeeWalletId: null,
|
||||
currency: request.Currency,
|
||||
amount: request.Amount,
|
||||
remarks: request.Remark
|
||||
);
|
||||
|
||||
return Ok(transaction);
|
||||
}
|
||||
}
|
||||
49
DysonNetwork.Pass/Wallet/WalletService.cs
Normal file
49
DysonNetwork.Pass/Wallet/WalletService.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Pass.Wallet;
|
||||
|
||||
public class WalletService(AppDatabase db)
|
||||
{
|
||||
public async Task<Wallet?> GetWalletAsync(Guid accountId)
|
||||
{
|
||||
return await db.Wallets
|
||||
.Include(w => w.Pockets)
|
||||
.FirstOrDefaultAsync(w => w.AccountId == accountId);
|
||||
}
|
||||
|
||||
public async Task<Wallet> CreateWalletAsync(Guid accountId)
|
||||
{
|
||||
var existingWallet = await db.Wallets.FirstOrDefaultAsync(w => w.AccountId == accountId);
|
||||
if (existingWallet != null)
|
||||
{
|
||||
throw new InvalidOperationException($"Wallet already exists for account {accountId}");
|
||||
}
|
||||
|
||||
var wallet = new Wallet { AccountId = accountId };
|
||||
|
||||
db.Wallets.Add(wallet);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return wallet;
|
||||
}
|
||||
|
||||
public async Task<(WalletPocket wallet, bool isNewlyCreated)> GetOrCreateWalletPocketAsync(
|
||||
Guid walletId,
|
||||
string currency,
|
||||
decimal? initialAmount = null
|
||||
)
|
||||
{
|
||||
var pocket = await db.WalletPockets.FirstOrDefaultAsync(p => p.Currency == currency && p.WalletId == walletId);
|
||||
if (pocket != null) return (pocket, false);
|
||||
|
||||
pocket = new WalletPocket
|
||||
{
|
||||
Currency = currency,
|
||||
Amount = initialAmount ?? 0,
|
||||
WalletId = walletId
|
||||
};
|
||||
|
||||
db.WalletPockets.Add(pocket);
|
||||
return (pocket, true);
|
||||
}
|
||||
}
|
||||
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; }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user