♻️ Move auth logic from pass to padlock

This commit is contained in:
2026-03-06 22:27:52 +08:00
parent 12556c4e26
commit a0e929dbcf
35 changed files with 5339 additions and 43 deletions

View File

@@ -8,40 +8,50 @@ var cache = builder.AddRedis("Cache");
var queue = builder.AddNats("Queue").WithJetStream();
var ringService = builder.AddProject<Projects.DysonNetwork_Ring>("ring");
var passService = builder.AddProject<Projects.DysonNetwork_Pass>("pass")
var padlockService = builder.AddProject<Projects.DysonNetwork_Padlock>("padlock")
.WithReference(ringService);
var passService = builder.AddProject<Projects.DysonNetwork_Pass>("pass")
.WithReference(ringService)
.WithReference(padlockService);
var driveService = builder.AddProject<Projects.DysonNetwork_Drive>("drive")
.WithReference(passService)
.WithReference(ringService);
.WithReference(ringService)
.WithReference(padlockService);
var sphereService = builder.AddProject<Projects.DysonNetwork_Sphere>("sphere")
.WithReference(passService)
.WithReference(ringService)
.WithReference(driveService);
.WithReference(driveService)
.WithReference(padlockService);
var developService = builder.AddProject<Projects.DysonNetwork_Develop>("develop")
.WithReference(passService)
.WithReference(ringService)
.WithReference(sphereService);
.WithReference(sphereService)
.WithReference(padlockService);
var insightService = builder.AddProject<Projects.DysonNetwork_Insight>("insight")
.WithReference(passService)
.WithReference(ringService)
.WithReference(sphereService)
.WithReference(developService);
.WithReference(developService)
.WithReference(padlockService);
var zoneService = builder.AddProject<Projects.DysonNetwork_Zone>("zone")
.WithReference(passService)
.WithReference(ringService)
.WithReference(sphereService)
.WithReference(developService)
.WithReference(insightService);
.WithReference(insightService)
.WithReference(padlockService);
var messagerService = builder.AddProject<Projects.DysonNetwork_Messager>("messager")
.WithReference(passService)
.WithReference(ringService)
.WithReference(sphereService)
.WithReference(developService)
.WithReference(driveService);
.WithReference(driveService)
.WithReference(padlockService);
var walletService = builder.AddProject<Projects.DysonNetwork_Wallet>("wallet")
.WithReference(passService)
.WithReference(ringService);
.WithReference(ringService)
.WithReference(padlockService);
var bladeService = builder.AddExternalService("blade", "http://localhost:7001");
@@ -50,6 +60,7 @@ passService.WithReference(developService).WithReference(driveService).WithRefere
List<IResourceBuilder<ProjectResource>> services =
[
ringService,
padlockService,
passService,
driveService,
sphereService,
@@ -89,4 +100,4 @@ ringService.WithReference(passService);
builder.AddDockerComposeEnvironment("docker-compose");
builder.Build().Run();
builder.Build().Run();

View File

@@ -17,6 +17,7 @@
<ItemGroup>
<ProjectReference Include="..\DysonNetwork.Develop\DysonNetwork.Develop.csproj" />
<ProjectReference Include="..\DysonNetwork.Drive\DysonNetwork.Drive.csproj" />
<ProjectReference Include="..\DysonNetwork.Padlock\DysonNetwork.Padlock.csproj" />
<ProjectReference Include="..\DysonNetwork.Pass\DysonNetwork.Pass.csproj" />
<ProjectReference Include="..\DysonNetwork.Ring\DysonNetwork.Ring.csproj" />
<ProjectReference Include="..\DysonNetwork.Sphere\DysonNetwork.Sphere.csproj" />

View File

@@ -0,0 +1,105 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Padlock.Auth;
using DysonNetwork.Shared.Extensions;
using DysonNetwork.Shared.Geometry;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Networking;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Padlock.Account;
[ApiController]
[Route("/api/accounts")]
public class AccountController(
AuthService auth,
AccountService accounts,
GeoService geo
) : ControllerBase
{
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(32)] public string Language { get; set; } = "en-us";
[Required] public string CaptchaToken { get; set; } = string.Empty;
}
public class AccountCreateValidateRequest
{
[MinLength(2)]
[MaxLength(256)]
[RegularExpression(@"^[A-Za-z0-9_-]+$",
ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.")]
public string? Name { get; set; }
[EmailAddress]
[RegularExpression(@"^[^+]+@[^@]+\.[^@]+$", ErrorMessage = "Email address cannot contain '+' symbol.")]
[MaxLength(1024)]
public string? Email { get; set; }
}
[HttpPost("validate")]
public async Task<ActionResult<string>> ValidateCreateAccountRequest([FromBody] AccountCreateValidateRequest request)
{
if (request.Name is not null && await accounts.CheckAccountNameHasTaken(request.Name))
return BadRequest("Account name has already been taken.");
if (request.Email is not null && await accounts.CheckEmailHasBeenUsed(request.Email))
return BadRequest("Email has already been used.");
return Ok("Everything seems good.");
}
[HttpPost]
public async Task<ActionResult<SnAccount>> CreateAccount([FromBody] AccountCreateRequest request)
{
if (!await auth.ValidateCaptcha(request.CaptchaToken))
return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
{
[nameof(request.CaptchaToken)] = ["Invalid captcha token."]
}, traceId: HttpContext.TraceIdentifier));
var ip = HttpContext.GetClientIpAddress();
var region = ip is null ? "us" : geo.GetFromIp(ip)?.Country.IsoCode ?? "us";
try
{
var account = await accounts.CreateAccount(
request.Name,
request.Nick,
request.Email,
request.Password,
request.Language,
region
);
return Ok(account);
}
catch (Exception ex)
{
return BadRequest(new ApiError
{
Code = "BAD_REQUEST",
Message = "Failed to create account.",
Detail = ex.Message,
Status = 400,
TraceId = HttpContext.TraceIdentifier
});
}
}
}

View File

@@ -0,0 +1,276 @@
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Networking;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Padlock.Account;
[Authorize]
[ApiController]
[Route("/api/accounts/me")]
public class AccountSecurityController(
AppDatabase db,
AccountService accounts
) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<SnAccount>> GetCurrentIdentity()
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var account = await db.Accounts.Where(e => e.Id == currentUser.Id).FirstOrDefaultAsync();
return Ok(account);
}
[HttpGet("factors")]
public async Task<ActionResult<List<SnAccountAuthFactor>>> GetAuthFactors()
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var factors = await db.AccountAuthFactors
.Where(f => f.AccountId == currentUser.Id)
.ToListAsync();
return Ok(factors);
}
public class AuthFactorRequest
{
public AccountAuthFactorType Type { get; set; }
public string? Secret { get; set; }
}
[HttpPost("factors")]
public async Task<ActionResult<SnAccountAuthFactor>> CreateAuthFactor([FromBody] AuthFactorRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
if (await accounts.CheckAuthFactorExists(currentUser, request.Type))
return BadRequest(ApiError.Validation(new Dictionary<string, string[]>
{
["factor"] = [$"Auth factor with type {request.Type} already exists."]
}, traceId: HttpContext.TraceIdentifier));
var factor = await accounts.CreateAuthFactor(currentUser, request.Type, request.Secret);
return factor is null ? BadRequest("Invalid factor request.") : Ok(factor);
}
[HttpPost("factors/{id:guid}/enable")]
public async Task<ActionResult<SnAccountAuthFactor>> EnableAuthFactor(Guid id, [FromBody] string? code)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var factor = await db.AccountAuthFactors
.Where(f => f.AccountId == currentUser.Id && f.Id == id)
.FirstOrDefaultAsync();
if (factor is null) return NotFound(ApiError.NotFound(id.ToString(), traceId: HttpContext.TraceIdentifier));
try
{
factor = await accounts.EnableAuthFactor(factor, code);
return Ok(factor);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpPost("factors/{id:guid}/disable")]
public async Task<ActionResult<SnAccountAuthFactor>> DisableAuthFactor(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount 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}")]
public async Task<ActionResult> DeleteAuthFactor(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var factor = await db.AccountAuthFactors
.Where(f => f.AccountId == currentUser.Id && f.Id == id)
.FirstOrDefaultAsync();
if (factor is null) return NotFound();
await accounts.DeleteAuthFactor(factor);
return NoContent();
}
[HttpGet("devices")]
public async Task<ActionResult<List<SnAuthClientWithSessions>>> GetDevices()
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
var devices = await db.AuthClients
.Where(device => device.AccountId == currentUser.Id)
.ToListAsync();
var sessionDevices = devices.ConvertAll(SnAuthClientWithSessions.FromClient).ToList();
var clientIds = sessionDevices.Select(x => x.Id).ToList();
var authSessions = await db.AuthSessions
.Where(c => c.ClientId != null && clientIds.Contains(c.ClientId.Value))
.GroupBy(c => c.ClientId!.Value)
.ToDictionaryAsync(c => c.Key, c => c.ToList());
foreach (var dev in sessionDevices)
if (authSessions.TryGetValue(dev.Id, out var challenge))
dev.Sessions = challenge;
return Ok(sessionDevices);
}
[HttpGet("sessions")]
public async Task<ActionResult<List<SnAuthSession>>> GetSessions([FromQuery] int take = 20, [FromQuery] int offset = 0)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
var query = db.AuthSessions
.OrderByDescending(x => x.LastGrantedAt)
.Where(session => session.AccountId == 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.Skip(offset).Take(take).ToListAsync();
return Ok(sessions);
}
[HttpDelete("sessions/{id:guid}")]
public async Task<ActionResult> DeleteSession(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
await accounts.DeleteSession(currentUser, id);
return NoContent();
}
[HttpDelete("devices/{deviceId}")]
public async Task<ActionResult> DeleteDevice(string deviceId)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
await accounts.DeleteDevice(currentUser, deviceId);
return NoContent();
}
[HttpDelete("sessions/current")]
public async Task<ActionResult> DeleteCurrentSession()
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
await accounts.DeleteSession(currentUser, currentSession.Id);
return NoContent();
}
[HttpPatch("devices/{deviceId}/label")]
public async Task<ActionResult> UpdateDeviceLabel(string deviceId, [FromBody] string label)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
await accounts.UpdateDeviceName(currentUser, deviceId, label);
return NoContent();
}
[HttpPatch("devices/current/label")]
public async Task<ActionResult> UpdateCurrentDeviceLabel([FromBody] string label)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
var device = await db.AuthClients.FirstOrDefaultAsync(d => d.Id == currentSession.ClientId);
if (device is null) return NotFound();
await accounts.UpdateDeviceName(currentUser, device.DeviceId, label);
return NoContent();
}
[HttpGet("contacts")]
public async Task<ActionResult<List<SnAccountContact>>> GetContacts()
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var contacts = await db.AccountContacts.Where(c => c.AccountId == currentUser.Id).ToListAsync();
return Ok(contacts);
}
public class AccountContactRequest
{
public AccountContactType Type { get; set; }
public string Content { get; set; } = null!;
}
[HttpPost("contacts")]
public async Task<ActionResult<SnAccountContact>> CreateContact([FromBody] AccountContactRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var contact = await accounts.CreateContactMethod(currentUser, request.Type, request.Content);
return Ok(contact);
}
[HttpPost("contacts/{id:guid}/verify")]
public async Task<ActionResult<SnAccountContact>> VerifyContact(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var contact = await db.AccountContacts.Where(c => c.AccountId == currentUser.Id && c.Id == id).FirstOrDefaultAsync();
if (contact is null) return NotFound();
await accounts.VerifyContactMethod(currentUser, contact);
return Ok(contact);
}
[HttpPost("contacts/{id:guid}/primary")]
public async Task<ActionResult<SnAccountContact>> SetPrimaryContact(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var contact = await db.AccountContacts.Where(c => c.AccountId == currentUser.Id && c.Id == id).FirstOrDefaultAsync();
if (contact is null) return NotFound();
contact = await accounts.SetContactMethodPrimary(currentUser, contact);
return Ok(contact);
}
[HttpPost("contacts/{id:guid}/public")]
public async Task<ActionResult<SnAccountContact>> SetPublicContact(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var contact = await db.AccountContacts.Where(c => c.AccountId == currentUser.Id && c.Id == id).FirstOrDefaultAsync();
if (contact is null) return NotFound();
contact = await accounts.SetContactMethodPublic(currentUser, contact, true);
return Ok(contact);
}
[HttpDelete("contacts/{id:guid}/public")]
public async Task<ActionResult<SnAccountContact>> UnsetPublicContact(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var contact = await db.AccountContacts.Where(c => c.AccountId == currentUser.Id && c.Id == id).FirstOrDefaultAsync();
if (contact is null) return NotFound();
contact = await accounts.SetContactMethodPublic(currentUser, contact, false);
return Ok(contact);
}
[HttpDelete("contacts/{id:guid}")]
public async Task<ActionResult> DeleteContact(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var contact = await db.AccountContacts.Where(c => c.AccountId == currentUser.Id && c.Id == id).FirstOrDefaultAsync();
if (contact is null) return NotFound();
await accounts.DeleteContactMethod(currentUser, contact);
return NoContent();
}
}

View File

@@ -0,0 +1,333 @@
using DysonNetwork.Padlock.Auth.OpenId;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.EventBus;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Queue;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Padlock.Account;
public class AccountService(
AppDatabase db,
ICacheService cache,
IEventBus eventBus
)
{
private const string AuthFactorCachePrefix = "authfactor:";
public async Task<SnAccount?> LookupAccount(string probe)
{
var account = await db.Accounts.Where(a => EF.Functions.ILike(a.Name, probe)).FirstOrDefaultAsync();
if (account is not null) return account;
var contact = await db.AccountContacts
.Where(c => c.Type == AccountContactType.Email || c.Type == AccountContactType.PhoneNumber)
.Where(c => EF.Functions.ILike(c.Content, probe))
.Include(c => c.Account)
.FirstOrDefaultAsync();
return contact?.Account;
}
public async Task<bool> CheckAccountNameHasTaken(string name)
{
return await db.Accounts.AnyAsync(a => EF.Functions.ILike(a.Name, name));
}
public async Task<bool> CheckEmailHasBeenUsed(string email)
{
return await db.AccountContacts.AnyAsync(c =>
c.Type == AccountContactType.Email && EF.Functions.ILike(c.Content, email));
}
public async Task<SnAccount> CreateAccount(
string name,
string nick,
string email,
string? password,
string language = "en-US",
string region = "en",
bool isEmailVerified = false,
bool isActivated = true
)
{
if (await CheckAccountNameHasTaken(name))
throw new InvalidOperationException("Account name has already been taken.");
if (await CheckEmailHasBeenUsed(email))
throw new InvalidOperationException("Account email has already been used.");
var account = new SnAccount
{
Name = name,
Nick = nick,
Language = language,
Region = region,
Contacts =
[
new SnAccountContact
{
Type = AccountContactType.Email,
Content = email,
VerifiedAt = isEmailVerified ? SystemClock.Instance.GetCurrentInstant() : null,
IsPrimary = true
}
],
AuthFactors = password is not null
?
[
new SnAccountAuthFactor
{
Type = AccountAuthFactorType.Password,
Secret = password,
EnabledAt = SystemClock.Instance.GetCurrentInstant()
}.HashSecret()
]
: [],
ActivatedAt = isActivated ? SystemClock.Instance.GetCurrentInstant() : null
};
db.Accounts.Add(account);
await db.SaveChangesAsync();
await PublishIdentityUpserted(account);
return account;
}
public async Task<SnAccount> CreateAccount(OidcUserInfo userInfo)
{
if (string.IsNullOrWhiteSpace(userInfo.Email))
throw new ArgumentException("Email is required for account creation");
var displayName = !string.IsNullOrWhiteSpace(userInfo.DisplayName)
? userInfo.DisplayName
: $"{userInfo.FirstName} {userInfo.LastName}".Trim();
var baseName = userInfo.Email.Split('@')[0].ToLowerInvariant();
var name = await GenerateAvailableUsername(baseName);
return await CreateAccount(
name,
string.IsNullOrWhiteSpace(displayName) ? name : displayName,
userInfo.Email,
null,
isEmailVerified: userInfo.EmailVerified
);
}
public async Task<bool> CheckAuthFactorExists(SnAccount account, AccountAuthFactorType type)
{
return await db.AccountAuthFactors
.Where(x => x.AccountId == account.Id && x.Type == type)
.AnyAsync();
}
public async Task<SnAccountAuthFactor?> CreateAuthFactor(SnAccount account, AccountAuthFactorType type, string? secret)
{
SnAccountAuthFactor? factor = type switch
{
AccountAuthFactorType.Password when !string.IsNullOrWhiteSpace(secret) => new SnAccountAuthFactor
{
Type = AccountAuthFactorType.Password,
Trustworthy = 1,
AccountId = account.Id,
Secret = secret,
EnabledAt = SystemClock.Instance.GetCurrentInstant(),
}.HashSecret(),
AccountAuthFactorType.EmailCode => new SnAccountAuthFactor
{
Type = AccountAuthFactorType.EmailCode,
Trustworthy = 2,
AccountId = account.Id,
EnabledAt = SystemClock.Instance.GetCurrentInstant(),
},
AccountAuthFactorType.InAppCode => new SnAccountAuthFactor
{
Type = AccountAuthFactorType.InAppCode,
Trustworthy = 2,
AccountId = account.Id,
EnabledAt = SystemClock.Instance.GetCurrentInstant(),
},
AccountAuthFactorType.TimedCode => new SnAccountAuthFactor
{
Type = AccountAuthFactorType.TimedCode,
Trustworthy = 3,
AccountId = account.Id,
Secret = secret ?? Guid.NewGuid().ToString("N"),
},
AccountAuthFactorType.PinCode when !string.IsNullOrWhiteSpace(secret) => new SnAccountAuthFactor
{
Type = AccountAuthFactorType.PinCode,
Trustworthy = 1,
AccountId = account.Id,
Secret = secret,
EnabledAt = SystemClock.Instance.GetCurrentInstant(),
}.HashSecret(),
_ => null
};
if (factor == null) return null;
db.AccountAuthFactors.Add(factor);
await db.SaveChangesAsync();
return factor;
}
public async Task<SnAccountAuthFactor> EnableAuthFactor(SnAccountAuthFactor factor, string? code)
{
if (factor.Type is AccountAuthFactorType.Password or AccountAuthFactorType.TimedCode)
{
factor.EnabledAt = SystemClock.Instance.GetCurrentInstant();
db.Update(factor);
await db.SaveChangesAsync();
return factor;
}
if (string.IsNullOrWhiteSpace(code) || !await VerifyFactorCode(factor, code))
throw new InvalidOperationException("Invalid factor code.");
factor.EnabledAt = SystemClock.Instance.GetCurrentInstant();
db.Update(factor);
await db.SaveChangesAsync();
return factor;
}
public async Task<SnAccountAuthFactor> DisableAuthFactor(SnAccountAuthFactor factor)
{
factor.EnabledAt = null;
db.Update(factor);
await db.SaveChangesAsync();
return factor;
}
public async Task DeleteAuthFactor(SnAccountAuthFactor factor)
{
db.AccountAuthFactors.Remove(factor);
await db.SaveChangesAsync();
}
public async Task SendFactorCode(SnAccount account, SnAccountAuthFactor factor)
{
var code = Random.Shared.Next(100000, 999999).ToString();
await cache.SetAsync($"{AuthFactorCachePrefix}{factor.Id}:code", code, TimeSpan.FromMinutes(10));
}
public async Task<bool> VerifyFactorCode(SnAccountAuthFactor factor, string code)
{
return factor.Type switch
{
AccountAuthFactorType.EmailCode or AccountAuthFactorType.InAppCode => await VerifyCachedFactorCode(factor, code),
AccountAuthFactorType.Password or AccountAuthFactorType.PinCode => BCrypt.Net.BCrypt.Verify(code, factor.Secret),
AccountAuthFactorType.TimedCode => factor.VerifyPassword(code),
_ => false
};
}
public async Task DeleteSession(SnAccount account, Guid sessionId)
{
var session = await db.AuthSessions.FirstOrDefaultAsync(s => s.AccountId == account.Id && s.Id == sessionId);
if (session == null) throw new InvalidOperationException("Session not found.");
session.ExpiredAt = SystemClock.Instance.GetCurrentInstant();
db.Update(session);
await db.SaveChangesAsync();
}
public async Task DeleteDevice(SnAccount account, string deviceId)
{
var client = await db.AuthClients.FirstOrDefaultAsync(c => c.AccountId == account.Id && c.DeviceId == deviceId);
if (client == null) throw new InvalidOperationException("Device not found.");
await db.AuthSessions
.Where(s => s.ClientId == client.Id)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ExpiredAt, SystemClock.Instance.GetCurrentInstant()));
}
public async Task UpdateDeviceName(SnAccount account, string deviceId, string label)
{
var client = await db.AuthClients.FirstOrDefaultAsync(c => c.AccountId == account.Id && c.DeviceId == deviceId);
if (client == null) throw new InvalidOperationException("Device not found.");
client.DeviceName = label;
db.Update(client);
await db.SaveChangesAsync();
}
public async Task<SnAccountContact> CreateContactMethod(SnAccount account, AccountContactType type, string content)
{
var contact = new SnAccountContact
{
AccountId = account.Id,
Type = type,
Content = content,
IsPrimary = false
};
db.AccountContacts.Add(contact);
await db.SaveChangesAsync();
return contact;
}
public async Task VerifyContactMethod(SnAccount account, SnAccountContact contact)
{
contact.VerifiedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(contact);
await db.SaveChangesAsync();
}
public async Task<SnAccountContact> SetContactMethodPrimary(SnAccount account, SnAccountContact contact)
{
await db.AccountContacts
.Where(c => c.AccountId == account.Id && c.Type == contact.Type)
.ExecuteUpdateAsync(s => s.SetProperty(c => c.IsPrimary, false));
contact.IsPrimary = true;
db.Update(contact);
await db.SaveChangesAsync();
return contact;
}
public async Task<SnAccountContact> SetContactMethodPublic(SnAccount account, SnAccountContact contact, bool isPublic)
{
contact.IsPublic = isPublic;
db.Update(contact);
await db.SaveChangesAsync();
return contact;
}
public async Task DeleteContactMethod(SnAccount account, SnAccountContact contact)
{
db.AccountContacts.Remove(contact);
await db.SaveChangesAsync();
}
private async Task PublishIdentityUpserted(SnAccount account)
{
await eventBus.PublishAsync(AccountIdentityUpsertedEvent.Type, new AccountIdentityUpsertedEvent
{
AccountId = account.Id,
Name = account.Name,
Nick = account.Nick,
Language = account.Language,
Region = account.Region,
ActivatedAt = account.ActivatedAt,
IsSuperuser = account.IsSuperuser,
UpdatedAt = SystemClock.Instance.GetCurrentInstant()
});
}
private async Task<bool> VerifyCachedFactorCode(SnAccountAuthFactor factor, string code)
{
var cached = await cache.GetAsync<string>($"{AuthFactorCachePrefix}{factor.Id}:code");
if (!string.Equals(cached, code, StringComparison.Ordinal))
return false;
await cache.RemoveAsync($"{AuthFactorCachePrefix}{factor.Id}:code");
return true;
}
private async Task<string> GenerateAvailableUsername(string baseName)
{
var normalized = new string(baseName.Where(c => char.IsLetterOrDigit(c) || c is '_' or '-').ToArray());
if (string.IsNullOrWhiteSpace(normalized)) normalized = "user";
var candidate = normalized;
var suffix = 1;
while (await CheckAccountNameHasTaken(candidate))
{
candidate = $"{normalized}{suffix++}";
}
return candidate;
}
}

View File

@@ -19,11 +19,8 @@ public class AppDatabase(
public DbSet<SnAccount> Accounts { get; set; } = null!;
public DbSet<SnAccountConnection> AccountConnections { get; set; } = null!;
public DbSet<SnAccountProfile> AccountProfiles { get; set; } = null!;
public DbSet<SnAccountContact> AccountContacts { get; set; } = null!;
public DbSet<SnAccountAuthFactor> AccountAuthFactors { get; set; } = null!;
public DbSet<SnAccountStatus> AccountStatuses { get; set; } = null!;
public DbSet<SnActionLog> ActionLogs { get; set; } = null!;
public DbSet<SnAuthSession> AuthSessions { get; set; } = null!;
public DbSet<SnAuthChallenge> AuthChallenges { get; set; } = null!;
@@ -76,6 +73,13 @@ public class AppDatabase(
{
base.OnModelCreating(modelBuilder);
// Padlock keeps auth/security ownership; relationship graph lives in Pass.
modelBuilder.Ignore<SnAccountProfile>();
modelBuilder.Ignore<SnAccountRelationship>();
modelBuilder.Entity<SnAccount>().Ignore(a => a.Profile);
modelBuilder.Entity<SnAccount>().Ignore(a => a.IncomingRelationships);
modelBuilder.Entity<SnAccount>().Ignore(a => a.OutgoingRelationships);
modelBuilder.Entity<SnPermissionGroupMember>()
.HasKey(pg => new { pg.GroupId, pg.Actor });
modelBuilder.Entity<SnPermissionGroupMember>()

View File

@@ -6,7 +6,7 @@ using DysonNetwork.Shared.Models;
namespace DysonNetwork.Padlock.Auth;
[ApiController]
[Route("api/v1/auth")]
[Route("/api/auth")]
public class AuthController(
AuthService auth,
AppDatabase db,
@@ -63,3 +63,9 @@ public class AuthController(
public record LoginRequest(string? Identifier, string? Password, string? DeviceId);
public record RefreshRequest(string RefreshToken);
public record SudoRequest(string? PinCode);
public class TokenExchangeResponse
{
public string Token { get; set; } = string.Empty;
public string? CookieDomain { get; set; }
public bool? IsSecure { get; set; }
}

View File

@@ -0,0 +1,429 @@
using System.Security.Cryptography;
using DysonNetwork.Padlock.Auth.OidcProvider.Responses;
using DysonNetwork.Padlock.Auth.OidcProvider.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using System.Text.Json.Serialization;
using System.Web;
using DysonNetwork.Padlock.Auth.OidcProvider.Options;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using DysonNetwork.Shared.Models;
using Microsoft.IdentityModel.Tokens;
namespace DysonNetwork.Padlock.Auth.OidcProvider.Controllers;
[Route("/api/auth/open")]
[ApiController]
public class OidcProviderController(
AppDatabase db,
OidcProviderService oidcService,
IConfiguration configuration,
IOptions<OidcProviderOptions> options,
ILogger<OidcProviderController> logger
) : ControllerBase
{
[HttpGet("authorize")]
[Produces("application/json")]
public async Task<IActionResult> Authorize(
[FromQuery(Name = "client_id")] string clientId,
[FromQuery(Name = "response_type")] string responseType,
[FromQuery(Name = "redirect_uri")] string? redirectUri = null,
[FromQuery] string? scope = null,
[FromQuery] string? state = null,
[FromQuery(Name = "response_mode")] string? responseMode = null,
[FromQuery] string? nonce = null,
[FromQuery] string? display = null,
[FromQuery] string? prompt = null,
[FromQuery(Name = "code_challenge")] string? codeChallenge = null,
[FromQuery(Name = "code_challenge_method")]
string? codeChallengeMethod = null)
{
if (string.IsNullOrEmpty(clientId))
{
return BadRequest(new ErrorResponse
{
Error = "invalid_request",
ErrorDescription = "client_id is required"
});
}
var client = await oidcService.FindClientBySlugAsync(clientId);
if (client == null)
{
return BadRequest(new ErrorResponse
{
Error = "unauthorized_client",
ErrorDescription = "Client not found"
});
}
// Validate response_type
if (string.IsNullOrEmpty(responseType))
{
return BadRequest(new ErrorResponse
{
Error = "invalid_request",
ErrorDescription = "response_type is required"
});
}
// Check if the client is allowed to use the requested response type
var allowedResponseTypes = new[] { "code", "token", "id_token" };
var requestedResponseTypes = responseType.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (requestedResponseTypes.Any(rt => !allowedResponseTypes.Contains(rt)))
{
return BadRequest(new ErrorResponse
{
Error = "unsupported_response_type",
ErrorDescription = "The requested response type is not supported"
});
}
// Validate redirect_uri if provided
if (!string.IsNullOrEmpty(redirectUri) &&
!await oidcService.ValidateRedirectUriAsync(client.Id, redirectUri))
{
return BadRequest(new ErrorResponse
{
Error = "invalid_request",
ErrorDescription = "Invalid redirect_uri"
});
}
// Return client information
var clientInfo = new ClientInfoResponse
{
ClientId = client.Id,
Picture = client.Picture,
Background = client.Background,
ClientName = client.Name,
HomeUri = client.Links?.HomePage,
PolicyUri = client.Links?.PrivacyPolicy,
TermsOfServiceUri = client.Links?.TermsOfService,
ResponseTypes = responseType,
Scopes = scope?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? [],
State = state,
Nonce = nonce,
CodeChallenge = codeChallenge,
CodeChallengeMethod = codeChallengeMethod
};
return Ok(clientInfo);
}
[HttpPost("authorize")]
[Consumes("application/x-www-form-urlencoded")]
[Authorize]
public async Task<IActionResult> HandleAuthorizationResponse(
[FromForm(Name = "authorize")] string? authorize,
[FromForm(Name = "client_id")] string clientId,
[FromForm(Name = "redirect_uri")] string? redirectUri = null,
[FromForm] string? scope = null,
[FromForm] string? state = null,
[FromForm] string? nonce = null,
[FromForm(Name = "code_challenge")] string? codeChallenge = null,
[FromForm(Name = "code_challenge_method")]
string? codeChallengeMethod = null)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount account)
return Unauthorized();
// Find the client
var client = await oidcService.FindClientBySlugAsync(clientId);
if (client == null)
{
return BadRequest(new ErrorResponse
{
Error = "unauthorized_client",
ErrorDescription = "Client not found"
});
}
// If user denied the request
if (string.IsNullOrEmpty(authorize) || !bool.TryParse(authorize, out var isAuthorized) || !isAuthorized)
{
var errorUri = new UriBuilder(redirectUri ?? client.Links?.HomePage ?? "https://example.com");
var queryParams = HttpUtility.ParseQueryString(errorUri.Query);
queryParams["error"] = "access_denied";
queryParams["error_description"] = "The user denied the authorization request";
if (!string.IsNullOrEmpty(state)) queryParams["state"] = state;
errorUri.Query = queryParams.ToString();
return Ok(new { redirectUri = errorUri.Uri.ToString() });
}
// Validate redirect_uri if provided
if (!string.IsNullOrEmpty(redirectUri) &&
!await oidcService.ValidateRedirectUriAsync(client!.Id, redirectUri))
{
return BadRequest(new ErrorResponse
{
Error = "invalid_request",
ErrorDescription = "Invalid redirect_uri"
});
}
// Default to client's first redirect URI if not provided
redirectUri ??= client.OauthConfig?.RedirectUris?.FirstOrDefault();
if (string.IsNullOrEmpty(redirectUri))
{
return BadRequest(new ErrorResponse
{
Error = "invalid_request",
ErrorDescription = "No valid redirect_uri available"
});
}
try
{
// Generate authorization code and create session
var authorizationCode = await oidcService.GenerateAuthorizationCodeAsync(
client.Id,
account.Id,
redirectUri,
scope?.Split(' ') ?? [],
codeChallenge,
codeChallengeMethod,
nonce
);
// Build the redirect URI with the authorization code
var redirectBuilder = new UriBuilder(redirectUri);
var queryParams = HttpUtility.ParseQueryString(redirectBuilder.Query);
queryParams["code"] = authorizationCode;
if (!string.IsNullOrEmpty(state)) queryParams["state"] = state;
redirectBuilder.Query = queryParams.ToString();
return Ok(new { redirectUri = redirectBuilder.Uri.ToString() });
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing authorization request");
return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse
{
Error = "server_error",
ErrorDescription = "An error occurred while processing your request"
});
}
}
[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.FindClientBySlugAsync(request.ClientId);
if (client == null ||
!await oidcService.ValidateClientCredentialsAsync(client.Id, request.ClientSecret))
return BadRequest(new ErrorResponse
{ Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
// Generate tokens
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
clientId: client.Id,
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?.AppId is null || session.ExpiredAt < now)
{
return BadRequest(new ErrorResponse
{
Error = "invalid_grant",
ErrorDescription = "Invalid or expired refresh token"
});
}
// Get the client
var client = await oidcService.FindClientByIdAsync(session.AppId.Value);
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 SnAccount currentUser ||
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
// Get requested scopes from the token
var scopes = currentSession.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 siteUrl = configuration["SiteUrl"];
var issuer = options.Value.IssuerUri.TrimEnd('/');
return Ok(new
{
issuer,
authorization_endpoint = $"{siteUrl}/auth/authorize",
token_endpoint = $"{baseUrl}/padlock/auth/open/token",
userinfo_endpoint = $"{baseUrl}/padlock/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", "RS256" },
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 string? ClientId { get; set; }
[JsonPropertyName("client_secret")]
[FromForm(Name = "client_secret")]
public string? ClientSecret { get; set; }
[JsonPropertyName("refresh_token")]
[FromForm(Name = "refresh_token")]
public string? RefreshToken { get; set; }
[JsonPropertyName("scope")]
[FromForm(Name = "scope")]
public string? Scope { get; set; }
[JsonPropertyName("code_verifier")]
[FromForm(Name = "code_verifier")]
public string? CodeVerifier { get; set; }
}

View File

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

View File

@@ -0,0 +1,9 @@
namespace DysonNetwork.Padlock.Auth.OidcProvider.Models;
public class ExternalUserInfo
{
public string Provider { get; set; } = null!;
public string UserId { get; set; } = null!;
public string? Email { get; set; }
public string? Name { get; set; }
}

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Padlock.Auth.OidcProvider.Responses;
public class ClientInfoResponse
{
public Guid ClientId { get; set; }
public SnCloudFileReferenceObject? Picture { get; set; }
public SnCloudFileReferenceObject? Background { get; set; }
public string? ClientName { get; set; }
public string? HomeUri { get; set; }
public string? PolicyUri { get; set; }
public string? TermsOfServiceUri { get; set; }
public string? ResponseTypes { get; set; }
public string[]? Scopes { get; set; }
public string? State { get; set; }
public string? Nonce { get; set; }
public string? CodeChallenge { get; set; }
public string? CodeChallengeMethod { get; set; }
}

View File

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

View File

@@ -0,0 +1,28 @@
using System.Text.Json.Serialization;
namespace DysonNetwork.Padlock.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; }
[JsonPropertyName("onboarding_token")]
public string? OnboardingToken { get; set; }
}

View File

@@ -2,17 +2,25 @@ using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using DysonNetwork.Padlock.Auth.OidcProvider.Models;
using DysonNetwork.Padlock.Auth.OidcProvider.Options;
using DysonNetwork.Padlock.Auth.OidcProvider.Responses;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
using AccountContactType = DysonNetwork.Shared.Models.AccountContactType;
using JwtRegisteredClaimNames = Microsoft.IdentityModel.JsonWebTokens.JwtRegisteredClaimNames;
namespace DysonNetwork.Padlock.Auth.OidcProvider.Services;
public class OidcProviderService(
AppDatabase db,
AuthService auth,
DyCustomAppService.DyCustomAppServiceClient customApps,
ICacheService cache,
IOptions<OidcProviderOptions> options,
ILogger<OidcProviderService> logger
@@ -26,17 +34,627 @@ public class OidcProviderService(
private const string CodeChallengeMethodS256 = "S256";
private const string CodeChallengeMethodPlain = "PLAIN";
public (bool IsValid, JwtSecurityToken? Token) ValidateToken(string token)
public async Task<SnCustomApp?> FindClientByIdAsync(Guid clientId)
{
var cacheKey = $"{CacheKeyPrefixClientId}{clientId}";
var (found, cachedApp) = await cache.GetAsyncWithStatus<SnCustomApp>(cacheKey);
if (found && cachedApp != null)
{
return cachedApp;
}
var resp = await customApps.GetCustomAppAsync(new DyGetCustomAppRequest { Id = clientId.ToString() });
if (resp.App == null) return null;
var app = SnCustomApp.FromProtoValue(resp.App);
await cache.SetAsync(cacheKey, app, TimeSpan.FromMinutes(5));
return app;
}
public async Task<SnCustomApp?> FindClientBySlugAsync(string slug)
{
var cacheKey = $"{CacheKeyPrefixClientSlug}{slug}";
var (found, cachedApp) = await cache.GetAsyncWithStatus<SnCustomApp>(cacheKey);
if (found && cachedApp != null)
return cachedApp;
var resp = await customApps.GetCustomAppAsync(new DyGetCustomAppRequest { Slug = slug });
if (resp.App == null) return null;
var app = SnCustomApp.FromProtoValue(resp.App);
await cache.SetAsync(cacheKey, app, TimeSpan.FromMinutes(5));
return app;
}
private async Task<SnAuthSession?> FindValidSessionAsync(Guid accountId, Guid clientId, bool withAccount = false)
{
var now = SystemClock.Instance.GetCurrentInstant();
var queryable = db.AuthSessions
.AsQueryable();
if (withAccount)
queryable = queryable
.Include(s => s.Account)
.Include(a => a.Account.Contacts)
.AsQueryable();
return await queryable
.Where(s => s.AccountId == accountId &&
s.AppId == clientId &&
(s.ExpiredAt == null || s.ExpiredAt > now) &&
s.Type == Shared.Models.SessionType.OAuth)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();
}
public async Task<bool> ValidateClientCredentialsAsync(Guid clientId, string clientSecret)
{
var resp = await customApps.CheckCustomAppSecretAsync(new DyCheckCustomAppSecretRequest
{
AppId = clientId.ToString(),
Secret = clientSecret,
IsOidc = true
});
return resp.Valid;
}
private static bool IsWildcardRedirectUriMatch(string allowedUri, string redirectUri)
{
if (string.IsNullOrEmpty(allowedUri) || string.IsNullOrEmpty(redirectUri))
return false;
// Check if it's an exact match
if (string.Equals(allowedUri, redirectUri, StringComparison.Ordinal))
return true;
// Quick check for wildcard patterns
if (!allowedUri.Contains('*'))
return false;
// Parse URIs once
Uri? allowedUriObj, redirectUriObj;
try
{
allowedUriObj = new Uri(allowedUri);
redirectUriObj = new Uri(redirectUri);
}
catch (UriFormatException)
{
return false;
}
// Check scheme and port matches
if (allowedUriObj.Scheme != redirectUriObj.Scheme || allowedUriObj.Port != redirectUriObj.Port)
{
return false;
}
var allowedHost = allowedUriObj.Host;
var redirectHost = redirectUriObj.Host;
// Handle wildcard domain patterns like *.example.com
if (allowedHost.StartsWith("*."))
{
var baseDomain = allowedHost[2..]; // Remove "*."
if (redirectHost == baseDomain || redirectHost.EndsWith("." + baseDomain))
{
// Check path match
var allowedPath = allowedUriObj.AbsolutePath.TrimEnd('/');
var redirectPath = redirectUriObj.AbsolutePath.TrimEnd('/');
// If allowed path is empty, any path is allowed
// If allowed path is specified, redirect path must start with it
return string.IsNullOrEmpty(allowedPath) ||
redirectPath.StartsWith(allowedPath, StringComparison.OrdinalIgnoreCase);
}
}
return false;
}
public async Task<bool> ValidateRedirectUriAsync(Guid clientId, string redirectUri)
{
if (string.IsNullOrEmpty(redirectUri))
return false;
var client = await FindClientByIdAsync(clientId);
if (client?.Status != CustomAppStatus.Production)
return true;
var redirectUris = client.OauthConfig?.RedirectUris;
if (redirectUris == null || redirectUris.Length == 0)
return false;
// Check each allowed URI for a match
return redirectUris.Any(allowedUri => IsWildcardRedirectUriMatch(allowedUri, redirectUri));
}
private string GenerateIdToken(
SnCustomApp client,
SnAuthSession session,
string? nonce = null,
IEnumerable<string>? scopes = null
)
{
var tokenHandler = new JwtSecurityTokenHandler();
var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant();
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Iss, _options.IssuerUri),
new(JwtRegisteredClaimNames.Sub, session.AccountId.ToString()),
new(JwtRegisteredClaimNames.Aud, client.Slug),
new(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
new(JwtRegisteredClaimNames.Exp,
now.Plus(Duration.FromSeconds(_options.AccessTokenLifetime.TotalSeconds)).ToUnixTimeSeconds()
.ToString(), ClaimValueTypes.Integer64),
new(JwtRegisteredClaimNames.AuthTime, session.CreatedAt.ToUnixTimeSeconds().ToString(),
ClaimValueTypes.Integer64),
};
// Add nonce if provided (required for implicit and hybrid flows)
if (!string.IsNullOrEmpty(nonce))
{
claims.Add(new Claim("nonce", nonce));
}
// Add email claim if email scope is requested
var scopesList = scopes?.ToList() ?? [];
if (scopesList.Contains("email"))
{
var contact = session.Account.Contacts.FirstOrDefault(c => c.Type == AccountContactType.Email);
if (contact is not null)
{
claims.Add(new Claim(JwtRegisteredClaimNames.Email, contact.Content));
claims.Add(new Claim("email_verified", contact.VerifiedAt is not null ? "true" : "false",
ClaimValueTypes.Boolean));
}
}
// Add profile claims if profile scope is requested
if (scopes != null && scopesList.Contains("profile"))
{
if (!string.IsNullOrEmpty(session.Account.Name))
claims.Add(new Claim("preferred_username", session.Account.Name));
if (!string.IsNullOrEmpty(session.Account.Nick))
claims.Add(new Claim("name", session.Account.Nick));
}
claims.Add(new Claim(JwtRegisteredClaimNames.Azp, client.Slug));
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Issuer = _options.IssuerUri,
Audience = client.Slug.ToString(),
Expires = now.Plus(Duration.FromSeconds(_options.AccessTokenLifetime.TotalSeconds)).ToDateTimeUtc(),
NotBefore = now.ToDateTimeUtc(),
SigningCredentials = new SigningCredentials(
new RsaSecurityKey(_options.GetRsaPrivateKey()),
SecurityAlgorithms.RsaSha256
)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
private async Task<(SnAuthSession session, string? nonce, List<string>? scopes)> HandleAuthorizationCodeFlowAsync(
AuthorizationCodeInfo authCode,
Guid clientId
)
{
if (authCode.AccountId == null)
throw new InvalidOperationException("Invalid authorization code, account id is missing.");
// Load the session for the user
var existingSession = await FindValidSessionAsync(authCode.AccountId.Value, clientId, withAccount: true);
SnAuthSession session;
if (existingSession == null)
{
var account = await db.Accounts
.Where(a => a.Id == authCode.AccountId)
.Include(a => a.Contacts)
.FirstOrDefaultAsync();
if (account == null) throw new InvalidOperationException("Account not found");
session = await auth.CreateSessionForOidcAsync(account, SystemClock.Instance.GetCurrentInstant(), clientId);
session.Account = account;
}
else
{
session = existingSession;
}
return (session, authCode.Nonce, authCode.Scopes);
}
private async Task<(SnAuthSession session, string? nonce, List<string>? scopes)> HandleRefreshTokenFlowAsync(
Guid sessionId)
{
var session = await FindSessionByIdAsync(sessionId) ??
throw new InvalidOperationException("Session not found");
// Verify the session is still valid
var now = SystemClock.Instance.GetCurrentInstant();
if (session.ExpiredAt.HasValue && session.ExpiredAt < now)
throw new InvalidOperationException("Session has expired");
return (session, null, null);
}
public async Task<TokenResponse> GenerateTokenResponseAsync(
Guid clientId,
string? authorizationCode = null,
string? redirectUri = null,
string? codeVerifier = null,
Guid? sessionId = null
)
{
if (clientId == Guid.Empty) throw new ArgumentException("Client ID cannot be empty", nameof(clientId));
var client = await FindClientByIdAsync(clientId) ?? throw new InvalidOperationException("Client not found");
if (authorizationCode != null)
{
var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
if (authCode == null)
{
throw new InvalidOperationException("Invalid authorization code");
}
if (authCode.AccountId.HasValue)
{
var (session, nonce, scopes) = await HandleAuthorizationCodeFlowAsync(authCode, clientId);
var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant();
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
// Generate tokens
var accessToken = GenerateJwtToken(client, session, expiresAt, scopes);
var idToken = GenerateIdToken(client, session, nonce, scopes);
var refreshToken = GenerateRefreshToken(session);
return new TokenResponse
{
AccessToken = accessToken,
IdToken = idToken,
ExpiresIn = expiresIn,
TokenType = "Bearer",
RefreshToken = refreshToken,
Scope = scopes != null ? string.Join(" ", scopes) : null
};
}
if (authCode.ExternalUserInfo != null)
{
var onboardingToken =
GenerateOnboardingToken(client, authCode.ExternalUserInfo, authCode.Nonce);
return new TokenResponse
{
OnboardingToken = onboardingToken,
TokenType = "Onboarding"
};
}
throw new InvalidOperationException("Invalid authorization code state.");
}
if (sessionId.HasValue)
{
var (session, nonce, scopes) = await HandleRefreshTokenFlowAsync(sessionId.Value);
var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant();
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
var accessToken = GenerateJwtToken(client, session, expiresAt, scopes);
var idToken = GenerateIdToken(client, session, nonce, scopes);
var refreshToken = GenerateRefreshToken(session);
return new TokenResponse
{
AccessToken = accessToken,
IdToken = idToken,
ExpiresIn = expiresIn,
TokenType = "Bearer",
RefreshToken = refreshToken,
Scope = scopes != null ? string.Join(" ", scopes) : null
};
}
throw new InvalidOperationException("Either authorization code or session ID must be provided");
}
private string GenerateOnboardingToken(
SnCustomApp client,
ExternalUserInfo externalUserInfo,
string? nonce
)
{
var tokenHandler = new JwtSecurityTokenHandler();
var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant();
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Iss, _options.IssuerUri),
new(JwtRegisteredClaimNames.Aud, client.Slug),
new(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
new(JwtRegisteredClaimNames.Exp,
now.Plus(Duration.FromMinutes(15)).ToUnixTimeSeconds()
.ToString(), ClaimValueTypes.Integer64),
new("provider", externalUserInfo.Provider),
new("provider_user_id", externalUserInfo.UserId)
};
if (!string.IsNullOrEmpty(externalUserInfo.Email))
{
claims.Add(new Claim(JwtRegisteredClaimNames.Email, externalUserInfo.Email));
}
if (!string.IsNullOrEmpty(externalUserInfo.Name))
{
claims.Add(new Claim("name", externalUserInfo.Name));
}
if (!string.IsNullOrEmpty(nonce))
{
claims.Add(new Claim("nonce", nonce));
}
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Issuer = _options.IssuerUri,
Audience = client.Slug,
Expires = now.Plus(Duration.FromMinutes(15)).ToDateTimeUtc(),
NotBefore = now.ToDateTimeUtc(),
SigningCredentials = new SigningCredentials(
new RsaSecurityKey(_options.GetRsaPrivateKey()),
SecurityAlgorithms.RsaSha256
)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
private string GenerateJwtToken(
SnCustomApp client,
SnAuthSession 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(JwtRegisteredClaimNames.Azp, client.Slug),
]),
Expires = expiresAt.ToDateTimeUtc(),
Issuer = _options.IssuerUri,
Audience = client.Slug
};
// 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 handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(token);
return (true, jwtToken);
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
catch (Exception ex)
{
logger.LogError(ex, "Token validation failed");
return (false, null);
}
}
public async Task<SnAuthSession?> FindSessionByIdAsync(Guid sessionId)
{
return await db.AuthSessions
.Include(s => s.Account)
.FirstOrDefaultAsync(s => s.Id == sessionId);
}
private static string GenerateRefreshToken(SnAuthSession session)
{
return Convert.ToBase64String(session.Id.ToByteArray());
}
public async Task<string> GenerateAuthorizationCodeAsync(
Guid clientId,
Guid userId,
string redirectUri,
IEnumerable<string> scopes,
string? codeChallenge = null,
string? codeChallengeMethod = null,
string? nonce = null
)
{
var authCodeInfo = new AuthorizationCodeInfo
{
ClientId = clientId,
AccountId = userId,
RedirectUri = redirectUri,
Scopes = scopes.ToList(),
CodeChallenge = codeChallenge,
CodeChallengeMethod = codeChallengeMethod,
Nonce = nonce,
CreatedAt = SystemClock.Instance.GetCurrentInstant()
};
return await StoreAuthorizationCode(authCodeInfo);
}
public async Task<string> GenerateAuthorizationCodeAsync(
Guid clientId,
ExternalUserInfo externalUserInfo,
string redirectUri,
IEnumerable<string> scopes,
string? codeChallenge = null,
string? codeChallengeMethod = null,
string? nonce = null
)
{
var authCodeInfo = new AuthorizationCodeInfo
{
ClientId = clientId,
ExternalUserInfo = externalUserInfo,
RedirectUri = redirectUri,
Scopes = scopes.ToList(),
CodeChallenge = codeChallenge,
CodeChallengeMethod = codeChallengeMethod,
Nonce = nonce,
CreatedAt = SystemClock.Instance.GetCurrentInstant()
};
return await StoreAuthorizationCode(authCodeInfo);
}
private async Task<string> StoreAuthorizationCode(AuthorizationCodeInfo authCodeInfo)
{
var code = GenerateRandomString(32);
var cacheKey = $"{CacheKeyPrefixAuthCode}{code}";
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
logger.LogInformation("Generated authorization code for client {ClientId}", authCodeInfo.ClientId);
return code;
}
private async Task<AuthorizationCodeInfo?> ValidateAuthorizationCodeAsync(
string code,
Guid clientId,
string? redirectUri = null,
string? codeVerifier = null
)
{
var cacheKey = $"{CacheKeyPrefixAuthCode}{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
{
CodeChallengeMethodS256 => VerifyCodeChallenge(codeVerifier, authCode.CodeChallenge,
CodeChallengeMethodS256),
CodeChallengeMethodPlain => VerifyCodeChallenge(codeVerifier, authCode.CodeChallenge,
CodeChallengeMethodPlain),
_ => 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 != CodeChallengeMethodS256)
return method == CodeChallengeMethodPlain &&
string.Equals(codeVerifier, codeChallenge, StringComparison.Ordinal);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(codeVerifier));
var base64 = Base64UrlEncoder.Encode(hash);
return string.Equals(base64, codeChallenge, StringComparison.Ordinal);
}
}

View File

@@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
namespace DysonNetwork.Padlock.Auth.OpenId;
public class AppleMobileConnectRequest
{
[Required]
public required string IdentityToken { get; set; }
[Required]
public required string AuthorizationCode { get; set; }
}
public class AppleMobileSignInRequest : AppleMobileConnectRequest
{
[Required] [MaxLength(512)]
public required string DeviceId { get; set; }
[MaxLength(1024)] public string? DeviceName { get; set; }
}

View File

@@ -0,0 +1,425 @@
using DysonNetwork.Padlock.Account;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DysonNetwork.Shared.Cache;
using Microsoft.AspNetCore.WebUtilities;
using NodaTime;
using DysonNetwork.Shared.Models;
namespace DysonNetwork.Padlock.Auth.OpenId;
[ApiController]
[Route("/api/accounts/me/connections")]
[Authorize]
public class ConnectionController(
AppDatabase db,
IEnumerable<OidcService> oidcServices,
AccountService accounts,
AuthService auth,
ICacheService cache,
IConfiguration configuration,
ILogger<ConnectionController> logger
) : 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<SnAccountConnection>>> GetConnections()
{
if (HttpContext.Items["CurrentUser"] is not SnAccount 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 SnAccount 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("/api/auth/connect/apple/mobile")]
public async Task<ActionResult> ConnectAppleMobile([FromBody] AppleMobileConnectRequest request)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount 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 SnAccountConnection
{
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; }
}
[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)
{
logger.LogWarning("Invalid or expired OIDC state: {State}", callbackData.State);
return BadRequest("Invalid or expired state parameter");
}
}
logger.LogInformation("OIDC callback for provider {Provider} with state {State} and flow {FlowType}", provider, callbackData.State, oidcState.FlowType);
// Remove the state from cache to prevent replay attacks
await cache.RemoveAsync(stateKey);
// Handle the flow based on state type
if (oidcState is { FlowType: OidcFlowType.Connect, AccountId: not null })
{
// Connection flow
if (oidcState.DeviceId != null)
{
callbackData.State = oidcState.DeviceId;
}
return await HandleManualConnection(provider, oidcService, callbackData, oidcState.AccountId.Value);
}
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 == "/")
{
logger.LogInformation("No returnUrl provided in OIDC state, will use default.");
return await HandleLoginOrRegistration(provider, oidcService, callbackData);
}
logger.LogInformation("Storing returnUrl {ReturnUrl} for state {State}", oidcState.ReturnUrl, callbackData.State);
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)
{
logger.LogError(ex, "Error processing OIDC callback for provider {Provider} during connection flow", provider);
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 SnAccountConnection
{
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 ex)
{
logger.LogError(ex, "Failed to save OIDC connection for provider {Provider}", provider);
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);
var siteUrl = configuration["SiteUrl"];
var redirectUrl = string.IsNullOrEmpty(returnUrl) ? siteUrl + "/auth/callback" : returnUrl;
logger.LogInformation("Redirecting after OIDC connection to {RedirectUrl}", redirectUrl);
return Redirect(redirectUrl);
}
private async Task<IActionResult> HandleLoginOrRegistration(
string provider,
OidcService oidcService,
OidcCallbackData callbackData
)
{
OidcUserInfo userInfo;
try
{
userInfo = await oidcService.ProcessCallbackAsync(callbackData);
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing OIDC callback for provider {Provider} during login/registration flow", provider);
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");
}
// Retrieve and clean up the return URL
var returnUrlKey = $"{ReturnUrlCachePrefix}{callbackData.State}";
var returnUrl = await cache.GetAsync<string>(returnUrlKey);
await cache.RemoveAsync(returnUrlKey);
var siteUrl = configuration["SiteUrl"];
var redirectBaseUrl = string.IsNullOrEmpty(returnUrl) ? siteUrl + "/auth/callback" : returnUrl;
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;
if (HttpContext.Items["CurrentSession"] is not SnAuthSession parentSession) parentSession = null;
var session = await oidcService.CreateSessionForUserAsync(
userInfo,
connection.Account,
HttpContext,
deviceId ?? string.Empty,
null,
ClientPlatform.Web,
parentSession);
var token = auth.CreateToken(session);
var redirectUrl = QueryHelpers.AddQueryString(redirectBaseUrl, "token", token);
logger.LogInformation("OIDC login successful for user {UserId}. Redirecting to {RedirectUrl}", connection.AccountId, redirectUrl);
return Redirect(redirectUrl);
}
// Register new user
var account = await accounts.LookupAccount(userInfo.Email) ?? await accounts.CreateAccount(userInfo);
// Create connection for new or existing user
var newConnection = new SnAccountConnection
{
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);
var finalRedirectUrl = QueryHelpers.AddQueryString(redirectBaseUrl, "token", loginToken);
logger.LogInformation("OIDC registration successful for new user {UserId}. Redirecting to {RedirectUrl}", account.Id, finalRedirectUrl);
return Redirect(finalRedirectUrl);
}
private static async Task<OidcCallbackData> ExtractCallbackData(HttpRequest request)
{
var data = new OidcCallbackData();
// Extract data based on request method
if (request.Method == "GET")
{
// Extract from query string
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() ?? "");
// Populate all query parameters for providers that need them (like Steam OpenID)
foreach (var param in request.Query)
{
data.QueryParameters[param.Key] = Uri.UnescapeDataString(param.Value.FirstOrDefault() ?? "");
}
}
else if (request.Method == "POST" && 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() ?? "");
// Populate all form parameters
foreach (var param in form)
{
data.QueryParameters[param.Key] = Uri.UnescapeDataString(param.Value.FirstOrDefault() ?? "");
}
}
return data;
}
}

View File

@@ -0,0 +1,212 @@
using DysonNetwork.Padlock.Account;
using DysonNetwork.Padlock.Auth;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using NodaTime;
namespace DysonNetwork.Padlock.Auth.OpenId;
[ApiController]
[Route("/api/auth/login")]
public class OidcController(
IServiceProvider serviceProvider,
AppDatabase db,
AccountService accounts,
AuthService auth,
ICacheService cache,
ILogger<OidcController> logger
)
: 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 = "/",
[FromQuery] string? deviceId = null,
[FromQuery] string? flow = null
)
{
logger.LogInformation("OIDC login request for provider {Provider} with returnUrl {ReturnUrl}, deviceId {DeviceId} and flow {Flow}", provider, returnUrl, deviceId, flow);
try
{
var oidcService = GetOidcService(provider);
// If the user is already authenticated, treat as an account connection request
if (flow != "login" && HttpContext.Items["CurrentUser"] is SnAccount 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);
logger.LogInformation("OIDC connection flow started for user {UserId} with state {State}", currentUser.Id, state);
// The state parameter sent to the provider is the GUID key for the cache.
var authUrl = await oidcService.GetAuthorizationUrlAsync(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);
logger.LogInformation("OIDC login flow started with state {State} and returnUrl {ReturnUrl}", state, oidcState.ReturnUrl);
var authUrl = await oidcService.GetAuthorizationUrlAsync(state, nonce);
return Redirect(authUrl);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error initiating OIDC flow for provider {Provider}", provider);
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<TokenExchangeResponse>> 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");
if (HttpContext.Items["CurrentSession"] is not SnAuthSession parentSession) parentSession = null;
// Create session using the OIDC service
var session = await appleService.CreateSessionForUserAsync(
userInfo,
account,
HttpContext,
request.DeviceId,
request.DeviceName,
ClientPlatform.Ios,
parentSession
);
var token = auth.CreateToken(session);
return Ok(new TokenExchangeResponse { Token = token });
}
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>(),
"spotify" => serviceProvider.GetRequiredService<SpotifyOidcService>(),
"steam" => serviceProvider.GetRequiredService<SteamOidcService>(),
"afdian" => serviceProvider.GetRequiredService<AfdianOidcService>(),
_ => throw new ArgumentException($"Unsupported provider: {provider}")
};
}
private async Task<SnAccount> 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 SnAccountConnection
{
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 SnAccountConnection
{
AccountId = newAccount.Id,
Provider = provider,
ProvidedIdentifier = userInfo.UserId!,
AccessToken = userInfo.AccessToken,
RefreshToken = userInfo.RefreshToken,
LastUsedAt = SystemClock.Instance.GetCurrentInstant(),
Meta = userInfo.ToMetadata()
};
db.AccountConnections.Add(newConnection);
await db.SaveChangesAsync();
return newAccount;
}
}

View File

@@ -0,0 +1,190 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DysonNetwork.Padlock.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
{
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
}
/// <summary>
/// Attempts to parse a state string into an OidcState object.
/// </summary>
public static bool TryParse(string? stateString, out OidcState? state)
{
state = null;
if (string.IsNullOrEmpty(stateString))
return false;
try
{
// First try to parse as JSON
try
{
state = JsonSerializer.Deserialize<OidcState>(stateString);
return state != null;
}
catch (JsonException)
{
// Not a JSON string, try legacy format for backward compatibility
return TryParseLegacyFormat(stateString, out state);
}
}
catch
{
return false;
}
}
private static bool TryParseLegacyFormat(string stateString, out OidcState? state)
{
state = null;
var parts = stateString.Split('|');
// Check for connection flow format: {accountId}|{provider}|{nonce}|{deviceId}|connect
if (parts.Length >= 5 &&
Guid.TryParse(parts[0], out var accountId) &&
string.Equals(parts[^1], "connect", StringComparison.OrdinalIgnoreCase))
{
state = new OidcState
{
FlowType = OidcFlowType.Connect,
AccountId = accountId,
Provider = parts[1],
Nonce = parts[2],
DeviceId = parts.Length >= 4 && !string.IsNullOrEmpty(parts[3]) ? parts[3] : null
};
return true;
}
// Check for login flow format: {returnUrl}|{deviceId}|login
if (parts.Length >= 2 &&
parts.Length <= 3 &&
(parts.Length < 3 || string.Equals(parts[^1], "login", StringComparison.OrdinalIgnoreCase)))
{
state = new OidcState
{
FlowType = OidcFlowType.Login,
ReturnUrl = parts[0],
DeviceId = parts.Length >= 2 && !string.IsNullOrEmpty(parts[1]) ? parts[1] : null
};
return true;
}
// Legacy format support (for backward compatibility)
if (parts.Length == 1)
{
state = new OidcState
{
FlowType = OidcFlowType.Login,
ReturnUrl = parts[0],
DeviceId = null
};
return true;
}
return false;
}
}
/// <summary>
/// Represents the type of OIDC flow.
/// </summary>
public enum OidcFlowType
{
/// <summary>
/// Login or registration flow.
/// </summary>
Login,
/// <summary>
/// Account connection flow.
/// </summary>
Connect
}

View File

@@ -79,7 +79,6 @@ public class TokenAuthService(
.AsNoTracking()
.Include(e => e.Client)
.Include(e => e.Account)
.ThenInclude(e => e.Profile)
.FirstOrDefaultAsync(s => s.Id == sessionId);
if (session is null)

View File

@@ -0,0 +1,913 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Padlock;
using DysonNetwork.Shared.Geometry;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DysonNetwork.Padlock.Migrations
{
[DbContext(typeof(AppDatabase))]
[Migration("20260306142133_InitialPadlockAuthSplit")]
partial class InitialPadlockAuthSplit
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Instant?>("ActivatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("activated_at");
b.Property<Guid?>("AutomatedId")
.HasColumnType("uuid")
.HasColumnName("automated_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<bool>("IsSuperuser")
.HasColumnType("boolean")
.HasColumnName("is_superuser");
b.Property<string>("Language")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("language");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("name");
b.Property<string>("Nick")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("nick");
b.Property<string>("Region")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("region");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_accounts");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("ix_accounts_name");
b.ToTable("accounts", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAccountAuthFactor", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Dictionary<string, object>>("Config")
.HasColumnType("jsonb")
.HasColumnName("config");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("EnabledAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("enabled_at");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("Secret")
.HasMaxLength(8196)
.HasColumnType("character varying(8196)")
.HasColumnName("secret");
b.Property<int>("Trustworthy")
.HasColumnType("integer")
.HasColumnName("trustworthy");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_account_auth_factors");
b.HasIndex("AccountId")
.HasDatabaseName("ix_account_auth_factors_account_id");
b.ToTable("account_auth_factors", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAccountBadge", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant?>("ActivatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("activated_at");
b.Property<string>("Caption")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("caption");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("Label")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("label");
b.Property<Dictionary<string, object>>("Meta")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_sn_account_badge");
b.HasIndex("AccountId")
.HasDatabaseName("ix_sn_account_badge_account_id");
b.ToTable("sn_account_badge", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAccountConnection", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("AccessToken")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("access_token");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("LastUsedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_used_at");
b.Property<Dictionary<string, object>>("Meta")
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<string>("ProvidedIdentifier")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("provided_identifier");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("provider");
b.Property<string>("RefreshToken")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("refresh_token");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_account_connections");
b.HasIndex("AccountId")
.HasDatabaseName("ix_account_connections_account_id");
b.ToTable("account_connections", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAccountContact", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("content");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<bool>("IsPrimary")
.HasColumnType("boolean")
.HasColumnName("is_primary");
b.Property<bool>("IsPublic")
.HasColumnType("boolean")
.HasColumnName("is_public");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<Instant?>("VerifiedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("verified_at");
b.HasKey("Id")
.HasName("pk_account_contacts");
b.HasIndex("AccountId")
.HasDatabaseName("ix_account_contacts_account_id");
b.ToTable("account_contacts", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnApiKey", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("label");
b.Property<Guid>("SessionId")
.HasColumnType("uuid")
.HasColumnName("session_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_api_keys");
b.HasIndex("AccountId")
.HasDatabaseName("ix_api_keys_account_id");
b.HasIndex("SessionId")
.HasDatabaseName("ix_api_keys_session_id");
b.ToTable("api_keys", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAuthChallenge", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.PrimitiveCollection<string>("Audiences")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("audiences");
b.PrimitiveCollection<string>("BlacklistFactors")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("blacklist_factors");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("device_id");
b.Property<string>("DeviceName")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("device_name");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<int>("FailedAttempts")
.HasColumnType("integer")
.HasColumnName("failed_attempts");
b.Property<string>("IpAddress")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("ip_address");
b.Property<GeoPoint>("Location")
.HasColumnType("jsonb")
.HasColumnName("location");
b.Property<string>("Nonce")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("nonce");
b.Property<int>("Platform")
.HasColumnType("integer")
.HasColumnName("platform");
b.PrimitiveCollection<string>("Scopes")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("scopes");
b.Property<int>("StepRemain")
.HasColumnType("integer")
.HasColumnName("step_remain");
b.Property<int>("StepTotal")
.HasColumnType("integer")
.HasColumnName("step_total");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("UserAgent")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("user_agent");
b.HasKey("Id")
.HasName("pk_auth_challenges");
b.HasIndex("AccountId")
.HasDatabaseName("ix_auth_challenges_account_id");
b.ToTable("auth_challenges", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAuthClient", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("device_id");
b.Property<string>("DeviceLabel")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("device_label");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("device_name");
b.Property<int>("Platform")
.HasColumnType("integer")
.HasColumnName("platform");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_auth_clients");
b.HasIndex("AccountId")
.HasDatabaseName("ix_auth_clients_account_id");
b.ToTable("auth_clients", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAuthSession", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Guid?>("AppId")
.HasColumnType("uuid")
.HasColumnName("app_id");
b.PrimitiveCollection<string>("Audiences")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("audiences");
b.Property<Guid?>("ChallengeId")
.HasColumnType("uuid")
.HasColumnName("challenge_id");
b.Property<Guid?>("ClientId")
.HasColumnType("uuid")
.HasColumnName("client_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("IpAddress")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("ip_address");
b.Property<Instant?>("LastGrantedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_granted_at");
b.Property<GeoPoint>("Location")
.HasColumnType("jsonb")
.HasColumnName("location");
b.Property<Guid?>("ParentSessionId")
.HasColumnType("uuid")
.HasColumnName("parent_session_id");
b.PrimitiveCollection<string>("Scopes")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("scopes");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("UserAgent")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("user_agent");
b.HasKey("Id")
.HasName("pk_auth_sessions");
b.HasIndex("AccountId")
.HasDatabaseName("ix_auth_sessions_account_id");
b.HasIndex("ClientId")
.HasDatabaseName("ix_auth_sessions_client_id");
b.HasIndex("ParentSessionId")
.HasDatabaseName("ix_auth_sessions_parent_session_id");
b.ToTable("auth_sessions", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnPermissionGroup", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("key");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_permission_groups");
b.ToTable("permission_groups", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnPermissionGroupMember", b =>
{
b.Property<Guid>("GroupId")
.HasColumnType("uuid")
.HasColumnName("group_id");
b.Property<string>("Actor")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("actor");
b.Property<Instant?>("AffectedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("affected_at");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("GroupId", "Actor")
.HasName("pk_permission_group_members");
b.ToTable("permission_group_members", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnPermissionNode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Actor")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("actor");
b.Property<Instant?>("AffectedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("affected_at");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<Guid?>("GroupId")
.HasColumnType("uuid")
.HasColumnName("group_id");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("key");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<JsonDocument>("Value")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("value");
b.HasKey("Id")
.HasName("pk_permission_nodes");
b.HasIndex("GroupId")
.HasDatabaseName("ix_permission_nodes_group_id");
b.HasIndex("Key", "Actor")
.HasDatabaseName("ix_permission_nodes_key_actor");
b.ToTable("permission_nodes", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAccountAuthFactor", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
.WithMany("AuthFactors")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_account_auth_factors_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAccountBadge", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
.WithMany("Badges")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_sn_account_badge_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAccountConnection", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
.WithMany("Connections")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_account_connections_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAccountContact", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
.WithMany("Contacts")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_account_contacts_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnApiKey", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_api_keys_accounts_account_id");
b.HasOne("DysonNetwork.Shared.Models.SnAuthSession", "Session")
.WithMany()
.HasForeignKey("SessionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_api_keys_auth_sessions_session_id");
b.Navigation("Account");
b.Navigation("Session");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAuthChallenge", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
.WithMany("Challenges")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_auth_challenges_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAuthClient", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_auth_clients_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAuthSession", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
.WithMany("Sessions")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_auth_sessions_accounts_account_id");
b.HasOne("DysonNetwork.Shared.Models.SnAuthClient", "Client")
.WithMany()
.HasForeignKey("ClientId")
.HasConstraintName("fk_auth_sessions_auth_clients_client_id");
b.HasOne("DysonNetwork.Shared.Models.SnAuthSession", "ParentSession")
.WithMany()
.HasForeignKey("ParentSessionId")
.HasConstraintName("fk_auth_sessions_auth_sessions_parent_session_id");
b.Navigation("Account");
b.Navigation("Client");
b.Navigation("ParentSession");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnPermissionGroupMember", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnPermissionGroup", "Group")
.WithMany("Members")
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_permission_group_members_permission_groups_group_id");
b.Navigation("Group");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnPermissionNode", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnPermissionGroup", "Group")
.WithMany("Nodes")
.HasForeignKey("GroupId")
.HasConstraintName("fk_permission_nodes_permission_groups_group_id");
b.Navigation("Group");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAccount", b =>
{
b.Navigation("AuthFactors");
b.Navigation("Badges");
b.Navigation("Challenges");
b.Navigation("Connections");
b.Navigation("Contacts");
b.Navigation("Sessions");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnPermissionGroup", b =>
{
b.Navigation("Members");
b.Navigation("Nodes");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,454 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Shared.Geometry;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Padlock.Migrations
{
/// <inheritdoc />
public partial class InitialPadlockAuthSplit : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "accounts",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
nick = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
language = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
region = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
activated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
is_superuser = table.Column<bool>(type: "boolean", nullable: false),
automated_id = table.Column<Guid>(type: "uuid", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_accounts", x => x.id);
});
migrationBuilder.CreateTable(
name: "permission_groups",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
key = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_permission_groups", x => x.id);
});
migrationBuilder.CreateTable(
name: "account_auth_factors",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
type = table.Column<int>(type: "integer", nullable: false),
secret = table.Column<string>(type: "character varying(8196)", maxLength: 8196, nullable: true),
config = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true),
trustworthy = table.Column<int>(type: "integer", nullable: false),
enabled_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_account_auth_factors", x => x.id);
table.ForeignKey(
name: "fk_account_auth_factors_accounts_account_id",
column: x => x.account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "account_connections",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
provider = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
provided_identifier = table.Column<string>(type: "character varying(8192)", maxLength: 8192, nullable: false),
meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true),
access_token = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
refresh_token = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
last_used_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_account_connections", x => x.id);
table.ForeignKey(
name: "fk_account_connections_accounts_account_id",
column: x => x.account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "account_contacts",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
type = table.Column<int>(type: "integer", nullable: false),
verified_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
is_primary = table.Column<bool>(type: "boolean", nullable: false),
is_public = table.Column<bool>(type: "boolean", nullable: false),
content = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_account_contacts", x => x.id);
table.ForeignKey(
name: "fk_account_contacts_accounts_account_id",
column: x => x.account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "auth_challenges",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
step_remain = table.Column<int>(type: "integer", nullable: false),
step_total = table.Column<int>(type: "integer", nullable: false),
failed_attempts = table.Column<int>(type: "integer", nullable: false),
blacklist_factors = table.Column<string>(type: "jsonb", nullable: false),
audiences = table.Column<string>(type: "jsonb", nullable: false),
scopes = table.Column<string>(type: "jsonb", nullable: false),
ip_address = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
user_agent = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
device_id = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
device_name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
platform = table.Column<int>(type: "integer", nullable: false),
nonce = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
location = table.Column<GeoPoint>(type: "jsonb", nullable: true),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_auth_challenges", x => x.id);
table.ForeignKey(
name: "fk_auth_challenges_accounts_account_id",
column: x => x.account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "auth_clients",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
platform = table.Column<int>(type: "integer", nullable: false),
device_name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
device_label = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
device_id = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_auth_clients", x => x.id);
table.ForeignKey(
name: "fk_auth_clients_accounts_account_id",
column: x => x.account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "sn_account_badge",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
type = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
label = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
caption = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: false),
activated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_sn_account_badge", x => x.id);
table.ForeignKey(
name: "fk_sn_account_badge_accounts_account_id",
column: x => x.account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "permission_group_members",
columns: table => new
{
group_id = table.Column<Guid>(type: "uuid", nullable: false),
actor = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
affected_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_permission_group_members", x => new { x.group_id, x.actor });
table.ForeignKey(
name: "fk_permission_group_members_permission_groups_group_id",
column: x => x.group_id,
principalTable: "permission_groups",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "permission_nodes",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
type = table.Column<int>(type: "integer", nullable: false),
actor = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
key = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
value = table.Column<JsonDocument>(type: "jsonb", nullable: false),
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
affected_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
group_id = table.Column<Guid>(type: "uuid", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_permission_nodes", x => x.id);
table.ForeignKey(
name: "fk_permission_nodes_permission_groups_group_id",
column: x => x.group_id,
principalTable: "permission_groups",
principalColumn: "id");
});
migrationBuilder.CreateTable(
name: "auth_sessions",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
type = table.Column<int>(type: "integer", nullable: false),
last_granted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
audiences = table.Column<string>(type: "jsonb", nullable: false),
scopes = table.Column<string>(type: "jsonb", nullable: false),
ip_address = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
user_agent = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
location = table.Column<GeoPoint>(type: "jsonb", nullable: true),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
client_id = table.Column<Guid>(type: "uuid", nullable: true),
parent_session_id = table.Column<Guid>(type: "uuid", nullable: true),
challenge_id = table.Column<Guid>(type: "uuid", nullable: true),
app_id = table.Column<Guid>(type: "uuid", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_auth_sessions", x => x.id);
table.ForeignKey(
name: "fk_auth_sessions_accounts_account_id",
column: x => x.account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_auth_sessions_auth_clients_client_id",
column: x => x.client_id,
principalTable: "auth_clients",
principalColumn: "id");
table.ForeignKey(
name: "fk_auth_sessions_auth_sessions_parent_session_id",
column: x => x.parent_session_id,
principalTable: "auth_sessions",
principalColumn: "id");
});
migrationBuilder.CreateTable(
name: "api_keys",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
label = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
session_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_api_keys", x => x.id);
table.ForeignKey(
name: "fk_api_keys_accounts_account_id",
column: x => x.account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_api_keys_auth_sessions_session_id",
column: x => x.session_id,
principalTable: "auth_sessions",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_account_auth_factors_account_id",
table: "account_auth_factors",
column: "account_id");
migrationBuilder.CreateIndex(
name: "ix_account_connections_account_id",
table: "account_connections",
column: "account_id");
migrationBuilder.CreateIndex(
name: "ix_account_contacts_account_id",
table: "account_contacts",
column: "account_id");
migrationBuilder.CreateIndex(
name: "ix_accounts_name",
table: "accounts",
column: "name",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_api_keys_account_id",
table: "api_keys",
column: "account_id");
migrationBuilder.CreateIndex(
name: "ix_api_keys_session_id",
table: "api_keys",
column: "session_id");
migrationBuilder.CreateIndex(
name: "ix_auth_challenges_account_id",
table: "auth_challenges",
column: "account_id");
migrationBuilder.CreateIndex(
name: "ix_auth_clients_account_id",
table: "auth_clients",
column: "account_id");
migrationBuilder.CreateIndex(
name: "ix_auth_sessions_account_id",
table: "auth_sessions",
column: "account_id");
migrationBuilder.CreateIndex(
name: "ix_auth_sessions_client_id",
table: "auth_sessions",
column: "client_id");
migrationBuilder.CreateIndex(
name: "ix_auth_sessions_parent_session_id",
table: "auth_sessions",
column: "parent_session_id");
migrationBuilder.CreateIndex(
name: "ix_permission_nodes_group_id",
table: "permission_nodes",
column: "group_id");
migrationBuilder.CreateIndex(
name: "ix_permission_nodes_key_actor",
table: "permission_nodes",
columns: new[] { "key", "actor" });
migrationBuilder.CreateIndex(
name: "ix_sn_account_badge_account_id",
table: "sn_account_badge",
column: "account_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "account_auth_factors");
migrationBuilder.DropTable(
name: "account_connections");
migrationBuilder.DropTable(
name: "account_contacts");
migrationBuilder.DropTable(
name: "api_keys");
migrationBuilder.DropTable(
name: "auth_challenges");
migrationBuilder.DropTable(
name: "permission_group_members");
migrationBuilder.DropTable(
name: "permission_nodes");
migrationBuilder.DropTable(
name: "sn_account_badge");
migrationBuilder.DropTable(
name: "auth_sessions");
migrationBuilder.DropTable(
name: "permission_groups");
migrationBuilder.DropTable(
name: "auth_clients");
migrationBuilder.DropTable(
name: "accounts");
}
}
}

View File

@@ -0,0 +1,910 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Padlock;
using DysonNetwork.Shared.Geometry;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DysonNetwork.Padlock.Migrations
{
[DbContext(typeof(AppDatabase))]
partial class AppDatabaseModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Instant?>("ActivatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("activated_at");
b.Property<Guid?>("AutomatedId")
.HasColumnType("uuid")
.HasColumnName("automated_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<bool>("IsSuperuser")
.HasColumnType("boolean")
.HasColumnName("is_superuser");
b.Property<string>("Language")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("language");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("name");
b.Property<string>("Nick")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("nick");
b.Property<string>("Region")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("region");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_accounts");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("ix_accounts_name");
b.ToTable("accounts", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAccountAuthFactor", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Dictionary<string, object>>("Config")
.HasColumnType("jsonb")
.HasColumnName("config");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("EnabledAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("enabled_at");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("Secret")
.HasMaxLength(8196)
.HasColumnType("character varying(8196)")
.HasColumnName("secret");
b.Property<int>("Trustworthy")
.HasColumnType("integer")
.HasColumnName("trustworthy");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_account_auth_factors");
b.HasIndex("AccountId")
.HasDatabaseName("ix_account_auth_factors_account_id");
b.ToTable("account_auth_factors", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAccountBadge", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant?>("ActivatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("activated_at");
b.Property<string>("Caption")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("caption");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("Label")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("label");
b.Property<Dictionary<string, object>>("Meta")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_sn_account_badge");
b.HasIndex("AccountId")
.HasDatabaseName("ix_sn_account_badge_account_id");
b.ToTable("sn_account_badge", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAccountConnection", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("AccessToken")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("access_token");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("LastUsedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_used_at");
b.Property<Dictionary<string, object>>("Meta")
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<string>("ProvidedIdentifier")
.IsRequired()
.HasMaxLength(8192)
.HasColumnType("character varying(8192)")
.HasColumnName("provided_identifier");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("provider");
b.Property<string>("RefreshToken")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("refresh_token");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_account_connections");
b.HasIndex("AccountId")
.HasDatabaseName("ix_account_connections_account_id");
b.ToTable("account_connections", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAccountContact", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("content");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<bool>("IsPrimary")
.HasColumnType("boolean")
.HasColumnName("is_primary");
b.Property<bool>("IsPublic")
.HasColumnType("boolean")
.HasColumnName("is_public");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<Instant?>("VerifiedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("verified_at");
b.HasKey("Id")
.HasName("pk_account_contacts");
b.HasIndex("AccountId")
.HasDatabaseName("ix_account_contacts_account_id");
b.ToTable("account_contacts", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnApiKey", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("label");
b.Property<Guid>("SessionId")
.HasColumnType("uuid")
.HasColumnName("session_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_api_keys");
b.HasIndex("AccountId")
.HasDatabaseName("ix_api_keys_account_id");
b.HasIndex("SessionId")
.HasDatabaseName("ix_api_keys_session_id");
b.ToTable("api_keys", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAuthChallenge", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.PrimitiveCollection<string>("Audiences")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("audiences");
b.PrimitiveCollection<string>("BlacklistFactors")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("blacklist_factors");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("device_id");
b.Property<string>("DeviceName")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("device_name");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<int>("FailedAttempts")
.HasColumnType("integer")
.HasColumnName("failed_attempts");
b.Property<string>("IpAddress")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("ip_address");
b.Property<GeoPoint>("Location")
.HasColumnType("jsonb")
.HasColumnName("location");
b.Property<string>("Nonce")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("nonce");
b.Property<int>("Platform")
.HasColumnType("integer")
.HasColumnName("platform");
b.PrimitiveCollection<string>("Scopes")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("scopes");
b.Property<int>("StepRemain")
.HasColumnType("integer")
.HasColumnName("step_remain");
b.Property<int>("StepTotal")
.HasColumnType("integer")
.HasColumnName("step_total");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("UserAgent")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("user_agent");
b.HasKey("Id")
.HasName("pk_auth_challenges");
b.HasIndex("AccountId")
.HasDatabaseName("ix_auth_challenges_account_id");
b.ToTable("auth_challenges", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAuthClient", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("device_id");
b.Property<string>("DeviceLabel")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("device_label");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("device_name");
b.Property<int>("Platform")
.HasColumnType("integer")
.HasColumnName("platform");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_auth_clients");
b.HasIndex("AccountId")
.HasDatabaseName("ix_auth_clients_account_id");
b.ToTable("auth_clients", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAuthSession", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Guid?>("AppId")
.HasColumnType("uuid")
.HasColumnName("app_id");
b.PrimitiveCollection<string>("Audiences")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("audiences");
b.Property<Guid?>("ChallengeId")
.HasColumnType("uuid")
.HasColumnName("challenge_id");
b.Property<Guid?>("ClientId")
.HasColumnType("uuid")
.HasColumnName("client_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("IpAddress")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("ip_address");
b.Property<Instant?>("LastGrantedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_granted_at");
b.Property<GeoPoint>("Location")
.HasColumnType("jsonb")
.HasColumnName("location");
b.Property<Guid?>("ParentSessionId")
.HasColumnType("uuid")
.HasColumnName("parent_session_id");
b.PrimitiveCollection<string>("Scopes")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("scopes");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("UserAgent")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("user_agent");
b.HasKey("Id")
.HasName("pk_auth_sessions");
b.HasIndex("AccountId")
.HasDatabaseName("ix_auth_sessions_account_id");
b.HasIndex("ClientId")
.HasDatabaseName("ix_auth_sessions_client_id");
b.HasIndex("ParentSessionId")
.HasDatabaseName("ix_auth_sessions_parent_session_id");
b.ToTable("auth_sessions", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnPermissionGroup", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("key");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_permission_groups");
b.ToTable("permission_groups", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnPermissionGroupMember", b =>
{
b.Property<Guid>("GroupId")
.HasColumnType("uuid")
.HasColumnName("group_id");
b.Property<string>("Actor")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("actor");
b.Property<Instant?>("AffectedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("affected_at");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("GroupId", "Actor")
.HasName("pk_permission_group_members");
b.ToTable("permission_group_members", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnPermissionNode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Actor")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("actor");
b.Property<Instant?>("AffectedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("affected_at");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<Guid?>("GroupId")
.HasColumnType("uuid")
.HasColumnName("group_id");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("key");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<JsonDocument>("Value")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("value");
b.HasKey("Id")
.HasName("pk_permission_nodes");
b.HasIndex("GroupId")
.HasDatabaseName("ix_permission_nodes_group_id");
b.HasIndex("Key", "Actor")
.HasDatabaseName("ix_permission_nodes_key_actor");
b.ToTable("permission_nodes", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAccountAuthFactor", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
.WithMany("AuthFactors")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_account_auth_factors_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAccountBadge", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
.WithMany("Badges")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_sn_account_badge_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAccountConnection", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
.WithMany("Connections")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_account_connections_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAccountContact", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
.WithMany("Contacts")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_account_contacts_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnApiKey", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_api_keys_accounts_account_id");
b.HasOne("DysonNetwork.Shared.Models.SnAuthSession", "Session")
.WithMany()
.HasForeignKey("SessionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_api_keys_auth_sessions_session_id");
b.Navigation("Account");
b.Navigation("Session");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAuthChallenge", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
.WithMany("Challenges")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_auth_challenges_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAuthClient", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_auth_clients_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAuthSession", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnAccount", "Account")
.WithMany("Sessions")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_auth_sessions_accounts_account_id");
b.HasOne("DysonNetwork.Shared.Models.SnAuthClient", "Client")
.WithMany()
.HasForeignKey("ClientId")
.HasConstraintName("fk_auth_sessions_auth_clients_client_id");
b.HasOne("DysonNetwork.Shared.Models.SnAuthSession", "ParentSession")
.WithMany()
.HasForeignKey("ParentSessionId")
.HasConstraintName("fk_auth_sessions_auth_sessions_parent_session_id");
b.Navigation("Account");
b.Navigation("Client");
b.Navigation("ParentSession");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnPermissionGroupMember", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnPermissionGroup", "Group")
.WithMany("Members")
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_permission_group_members_permission_groups_group_id");
b.Navigation("Group");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnPermissionNode", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnPermissionGroup", "Group")
.WithMany("Nodes")
.HasForeignKey("GroupId")
.HasConstraintName("fk_permission_nodes_permission_groups_group_id");
b.Navigation("Group");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAccount", b =>
{
b.Navigation("AuthFactors");
b.Navigation("Badges");
b.Navigation("Challenges");
b.Navigation("Connections");
b.Navigation("Contacts");
b.Navigation("Sessions");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnPermissionGroup", b =>
{
b.Navigation("Members");
b.Navigation("Nodes");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -12,6 +12,8 @@ builder.ConfigureAppKestrel(builder.Configuration);
builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppAuthentication();
builder.Services.AddBladeService();
builder.Services.AddRingService();
builder.Services.AddAppFlushHandlers();
builder.Services.AddAppBusinessServices(builder.Configuration);
@@ -31,7 +33,8 @@ using (var scope = app.Services.CreateScope())
await db.Database.MigrateAsync();
}
app.MapControllers();
app.ConfigureAppMiddleware(builder.Configuration);
app.ConfigureGrpcServices();
app.UseSwaggerManifest("DysonNetwork.Padlock");

View File

@@ -0,0 +1,32 @@
using DysonNetwork.Padlock.Auth;
using DysonNetwork.Shared.Networking;
namespace DysonNetwork.Padlock.Startup;
public static class ApplicationConfiguration
{
public static WebApplication ConfigureAppMiddleware(this WebApplication app, IConfiguration configuration)
{
app.MapOpenApi();
app.UseRequestLocalization();
app.ConfigureForwardedHeaders(configuration);
app.UseWebSockets();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers().RequireRateLimiting("fixed");
return app;
}
public static WebApplication ConfigureGrpcServices(this WebApplication app)
{
app.MapGrpcService<AuthServiceGrpc>();
app.MapGrpcReflectionService();
return app;
}
}

View File

@@ -1,6 +1,7 @@
using System.Globalization;
using DysonNetwork.Padlock.Auth;
using DysonNetwork.Padlock.Auth.OpenId;
using DysonNetwork.Padlock.Account;
using DysonNetwork.Padlock.Permission;
using DysonNetwork.Padlock.Localization;
using Microsoft.AspNetCore.RateLimiting;
@@ -129,12 +130,14 @@ public static class ServiceCollectionExtensions
return new DysonNetwork.Shared.Templating.DotLiquidTemplateService(assembly, resourceNamespace);
});
services.AddScoped<PermissionService>();
services.AddScoped<AccountService>();
services.AddSingleton<AuthTokenKeyProvider>();
services.AddScoped<AuthService>();
services.AddScoped<TokenAuthService>();
services.Configure<OidcProviderOptions>(configuration.GetSection("OidcProvider"));
services.AddScoped<OidcProviderService>();
services.AddEventBus();
return services;
}

View File

@@ -1,9 +1,72 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"Debug": true,
"BaseUrl": "http://localhost:5011",
"SiteUrl": "http://localhost:3000",
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_padlock;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
},
"Authentication": {
"Schemes": {
"Bearer": {
"ValidAudiences": [
"http://localhost:5071",
"https://localhost:7099"
],
"ValidIssuer": "solar-network"
}
}
},
"AuthToken": {
"CookieDomain": "localhost",
"PublicKeyPath": "Keys/PublicKey.pem",
"PrivateKeyPath": "Keys/PrivateKey.pem"
},
"OidcProvider": {
"IssuerUri": "https://nt.solian.app",
"PublicKeyPath": "Keys/PublicKey.pem",
"PrivateKeyPath": "Keys/PrivateKey.pem",
"AccessTokenLifetime": "01:00:00",
"RefreshTokenLifetime": "30.00:00:00",
"AuthorizationCodeLifetime": "00:30:00",
"RequireHttpsMetadata": true
},
"Captcha": {
"Provider": "cloudflare",
"ApiKey": "0x4AAAAAABCDUdOujj4feOb_",
"ApiSecret": "0x4AAAAAABCDUWABiJQweqlB7tYq-IqIm8U"
},
"GeoIp": {
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
},
"Oidc": {
"Google": {
"ClientId": "961776991058-963m1qin2vtp8fv693b5fdrab5hmpl89.apps.googleusercontent.com",
"ClientSecret": ""
},
"Apple": {
"ClientId": "dev.solsynth.solian",
"TeamId": "W7HPZ53V6B",
"KeyId": "B668YP4KBG",
"PrivateKeyPath": "./Keys/Solarpass.p8"
},
"Microsoft": {
"ClientId": "YOUR_MICROSOFT_CLIENT_ID",
"ClientSecret": "YOUR_MICROSOFT_CLIENT_SECRET",
"DiscoveryEndpoint": "YOUR_MICROSOFT_DISCOVERY_ENDPOINT"
}
},
"Cache": {
"Serializer": "JSON"
},
"KnownProxies": [
"127.0.0.1",
"::1"
]
}

View File

@@ -16,6 +16,7 @@ builder.Services.AddAppServices(builder.Configuration);
builder.Services.AddAppAuthentication();
builder.Services.AddBladeService();
builder.Services.AddRingService();
builder.Services.AddAuthService();
builder.Services.AddDriveService();
builder.Services.AddDevelopService();
builder.Services.AddWalletService();
@@ -48,4 +49,4 @@ app.ConfigureGrpcServices();
app.UseSwaggerManifest("DysonNetwork.Pass");
app.Run();
app.Run();

View File

@@ -1,5 +1,4 @@
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Auth;
using DysonNetwork.Pass.Credit;
using DysonNetwork.Pass.Leveling;
using DysonNetwork.Pass.Permission;
@@ -31,7 +30,6 @@ public static class ApplicationConfiguration
public static WebApplication ConfigureGrpcServices(this WebApplication app)
{
app.MapGrpcService<AccountServiceGrpc>();
app.MapGrpcService<AuthServiceGrpc>();
app.MapGrpcService<ActionLogServiceGrpc>();
app.MapGrpcService<PermissionServiceGrpc>();
app.MapGrpcService<SocialCreditServiceGrpc>();

View File

@@ -22,6 +22,7 @@ using DysonNetwork.Pass.Realm;
using DysonNetwork.Pass.Rewind;
using DysonNetwork.Pass.Safety;
using DysonNetwork.Pass.Ticket;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.EventBus;
@@ -30,6 +31,7 @@ using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Queue;
using DysonNetwork.Shared.Registry;
using DysonNetwork.Shared.Localization;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Pass.Startup;
@@ -117,10 +119,12 @@ public static class ServiceCollectionExtensions
services.AddAuthorization();
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = AuthConstants.SchemeName;
options.DefaultChallengeScheme = AuthConstants.SchemeName;
options.DefaultAuthenticateScheme = DysonNetwork.Shared.Auth.AuthConstants.SchemeName;
options.DefaultChallengeScheme = DysonNetwork.Shared.Auth.AuthConstants.SchemeName;
})
.AddScheme<DysonTokenAuthOptions, DysonTokenAuthHandler>(AuthConstants.SchemeName, _ => { });
.AddScheme<DysonNetwork.Shared.Auth.DysonTokenAuthOptions, DysonNetwork.Shared.Auth.DysonTokenAuthHandler>(
DysonNetwork.Shared.Auth.AuthConstants.SchemeName,
_ => { });
return services;
}
@@ -246,6 +250,40 @@ public static class ServiceCollectionExtensions
}
logger.LogInformation("Handled status update for user {AccountId} on disconnect", evt.AccountId);
})
.AddListener<AccountIdentityUpsertedEvent>(async (evt, ctx) =>
{
var db = ctx.ServiceProvider.GetRequiredService<AppDatabase>();
var logger = ctx.ServiceProvider.GetRequiredService<ILogger<EventBus>>();
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == evt.AccountId, ctx.CancellationToken);
if (account is null)
{
account = new SnAccount
{
Id = evt.AccountId,
Name = evt.Name,
Nick = evt.Nick,
Language = evt.Language,
Region = evt.Region,
ActivatedAt = evt.ActivatedAt,
IsSuperuser = evt.IsSuperuser
};
db.Accounts.Add(account);
}
else
{
account.Name = evt.Name;
account.Nick = evt.Nick;
account.Language = evt.Language;
account.Region = evt.Region;
account.ActivatedAt = evt.ActivatedAt;
account.IsSuperuser = evt.IsSuperuser;
db.Update(account);
}
await db.SaveChangesAsync(ctx.CancellationToken);
logger.LogInformation("Upserted account identity read model for {AccountId}", evt.AccountId);
});
return services;

View File

@@ -24,3 +24,19 @@ public class AccountStatusUpdatedEvent : EventBase
public SnAccountStatus Status { get; set; } = new();
public Instant UpdatedAt { get; set; } = SystemClock.Instance.GetCurrentInstant();
}
public class AccountIdentityUpsertedEvent : EventBase
{
public static string Type => "account_identity_upserted";
public override string EventType => Type;
public override string StreamName => "account_events";
public Guid AccountId { get; set; }
public string Name { get; set; } = string.Empty;
public string Nick { get; set; } = string.Empty;
public string Language { get; set; } = "en-US";
public string Region { get; set; } = "en";
public Instant? ActivatedAt { get; set; }
public bool IsSuperuser { get; set; }
public Instant UpdatedAt { get; set; } = SystemClock.Instance.GetCurrentInstant();
}

View File

@@ -30,7 +30,7 @@ public static class ServiceInjectionHelper
public IServiceCollection AddAuthService()
{
services.AddGrpcClientWithSharedChannel<DyAuthService.DyAuthServiceClient>(
"https://_grpc.pass",
"https://_grpc.padlock",
"DyAuthService");
services.AddGrpcClientWithSharedChannel<DyPermissionService.DyPermissionServiceClient>(
"https://_grpc.pass",

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
# One-time migration helper: copy auth-owned tables from PASS DB to PADLOCK DB.
# Required env vars:
# PASS_DB_URL (e.g. postgres://postgres:postgres@localhost:5432/dyson_pass)
# PADLOCK_DB_URL (e.g. postgres://postgres:postgres@localhost:5432/dyson_padlock)
if [[ -z "${PASS_DB_URL:-}" || -z "${PADLOCK_DB_URL:-}" ]]; then
echo "PASS_DB_URL and PADLOCK_DB_URL are required."
exit 1
fi
TABLES=(
"accounts"
"account_contacts"
"account_auth_factors"
"account_connections"
"auth_clients"
"auth_challenges"
"auth_sessions"
"api_keys"
"permission_groups"
"permission_group_members"
"permission_nodes"
)
echo "[1/4] Pre-migration counts (PASS):"
for t in "${TABLES[@]}"; do
printf " %-28s" "${t}"
psql "${PASS_DB_URL}" -Atc "select count(*) from ${t};"
done
tmpdir="$(mktemp -d)"
cleanup() { rm -rf "${tmpdir}"; }
trap cleanup EXIT
echo "[2/4] Export auth tables from PASS..."
for t in "${TABLES[@]}"; do
pg_dump "${PASS_DB_URL}" --data-only --table="${t}" --column-inserts > "${tmpdir}/${t}.sql"
done
echo "[3/4] Import into PADLOCK..."
for t in "${TABLES[@]}"; do
psql "${PADLOCK_DB_URL}" -v ON_ERROR_STOP=1 -f "${tmpdir}/${t}.sql"
done
echo "[4/4] Post-migration counts (PADLOCK):"
for t in "${TABLES[@]}"; do
printf " %-28s" "${t}"
psql "${PADLOCK_DB_URL}" -Atc "select count(*) from ${t};"
done
echo "Migration completed."

View File

@@ -43,10 +43,12 @@ services:
HTTPS_PORTS: "7001"
ConnectionStrings__queue: nats://nats:${QUEUE_PASSWORD}@queue:4222
services__pass__http__0: http://pass:8080
services__padlock__http__0: http://padlock:8080
OTEL_EXPORTER_OTLP_ENDPOINT: http://docker-compose-dashboard:18889
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: ring
services__pass__grpc__0: https://pass:5001
services__padlock__grpc__0: https://padlock:5001
expose:
- "8080"
- "7001"
@@ -64,10 +66,33 @@ services:
ConnectionStrings__cache: cache:6379,password=${CACHE_PASSWORD}
ConnectionStrings__queue: nats://nats:${QUEUE_PASSWORD}@queue:4222
services__ring__http__0: http://ring:8080
services__padlock__http__0: http://padlock:8080
OTEL_EXPORTER_OTLP_ENDPOINT: http://docker-compose-dashboard:18889
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: pass
services__ring__grpc__0: https://ring:5001
services__padlock__grpc__0: https://padlock:5001
expose:
- "8080"
- "7001"
networks:
- aspire
padlock:
image: ${PADLOCK_IMAGE}
environment:
OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES: "true"
OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EVENT_LOG_ATTRIBUTES: "true"
OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: in_memory
ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true"
HTTP_PORTS: "8080"
HTTPS_PORTS: "7001"
ConnectionStrings__cache: cache:6379,password=${CACHE_PASSWORD}
ConnectionStrings__queue: nats://nats:${QUEUE_PASSWORD}@queue:4222
services__ring__http__0: http://ring:8080
OTEL_EXPORTER_OTLP_ENDPOINT: http://docker-compose-dashboard:18889
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: padlock
services__ring__grpc__0: https://ring:5001
expose:
- "8080"
- "7001"
@@ -85,11 +110,13 @@ services:
ConnectionStrings__cache: cache:6379,password=${CACHE_PASSWORD}
ConnectionStrings__queue: nats://nats:${QUEUE_PASSWORD}@queue:4222
services__pass__http__0: http://pass:8080
services__padlock__http__0: http://padlock:8080
services__ring__http__0: http://ring:8080
OTEL_EXPORTER_OTLP_ENDPOINT: http://docker-compose-dashboard:18889
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: drive
services__pass__grpc__0: https://pass:5001
services__padlock__grpc__0: https://padlock:5001
services__ring__grpc__0: https://ring:5001
expose:
- "8080"
@@ -108,12 +135,14 @@ services:
ConnectionStrings__cache: cache:6379,password=${CACHE_PASSWORD}
ConnectionStrings__queue: nats://nats:${QUEUE_PASSWORD}@queue:4222
services__pass__http__0: http://pass:8080
services__padlock__http__0: http://padlock:8080
services__ring__http__0: http://ring:8080
services__drive__http__0: http://drive:8080
OTEL_EXPORTER_OTLP_ENDPOINT: http://docker-compose-dashboard:18889
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: sphere
services__pass__grpc__0: https://pass:5001
services__padlock__grpc__0: https://padlock:5001
services__drive__grpc__0: https://drive:5001
services__ring__grpc__0: https://ring:5001
expose:
@@ -132,11 +161,13 @@ services:
HTTPS_PORTS: "7001"
ConnectionStrings__cache: cache:6379,password=${CACHE_PASSWORD}
services__pass__http__0: http://pass:8080
services__padlock__http__0: http://padlock:8080
services__ring__http__0: http://ring:8080
OTEL_EXPORTER_OTLP_ENDPOINT: http://docker-compose-dashboard:18889
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: develop
services__pass__grpc__0: https://pass:5001
services__padlock__grpc__0: https://padlock:5001
services__ring__grpc__0: https://ring:5001
expose:
- "8080"
@@ -156,11 +187,11 @@ services:
REVERSEPROXY__ROUTES__route1__TRANSFORMS__0__PathRemovePrefix: /ring
REVERSEPROXY__ROUTES__route1__TRANSFORMS__1__PathPrefix: /api
REVERSEPROXY__ROUTES__route2__MATCH__PATH: /.well-known/openid-configuration
REVERSEPROXY__ROUTES__route2__CLUSTERID: cluster_pass
REVERSEPROXY__ROUTES__route2__CLUSTERID: cluster_padlock
REVERSEPROXY__ROUTES__route3__MATCH__PATH: /.well-known/jwks
REVERSEPROXY__ROUTES__route3__CLUSTERID: cluster_pass
REVERSEPROXY__ROUTES__route3__CLUSTERID: cluster_padlock
REVERSEPROXY__ROUTES__route4__MATCH__PATH: /id/{**catch-all}
REVERSEPROXY__ROUTES__route4__CLUSTERID: cluster_pass
REVERSEPROXY__ROUTES__route4__CLUSTERID: cluster_padlock
REVERSEPROXY__ROUTES__route4__TRANSFORMS__0__PathRemovePrefix: /id
REVERSEPROXY__ROUTES__route4__TRANSFORMS__1__PathPrefix: /api
REVERSEPROXY__ROUTES__route5__MATCH__PATH: /api/tus
@@ -173,17 +204,24 @@ services:
REVERSEPROXY__ROUTES__route7__CLUSTERID: cluster_sphere
REVERSEPROXY__ROUTES__route7__TRANSFORMS__0__PathRemovePrefix: /sphere
REVERSEPROXY__ROUTES__route7__TRANSFORMS__1__PathPrefix: /api
REVERSEPROXY__ROUTES__route8__MATCH__PATH: /develop/{**catch-all}
REVERSEPROXY__ROUTES__route8__CLUSTERID: cluster_develop
REVERSEPROXY__ROUTES__route8__TRANSFORMS__0__PathRemovePrefix: /develop
REVERSEPROXY__ROUTES__route8__MATCH__PATH: /padlock/{**catch-all}
REVERSEPROXY__ROUTES__route8__CLUSTERID: cluster_padlock
REVERSEPROXY__ROUTES__route8__TRANSFORMS__0__PathRemovePrefix: /padlock
REVERSEPROXY__ROUTES__route8__TRANSFORMS__1__PathPrefix: /api
REVERSEPROXY__ROUTES__route9__MATCH__PATH: /develop/{**catch-all}
REVERSEPROXY__ROUTES__route9__CLUSTERID: cluster_develop
REVERSEPROXY__ROUTES__route9__TRANSFORMS__0__PathRemovePrefix: /develop
REVERSEPROXY__ROUTES__route9__TRANSFORMS__1__PathPrefix: /api
REVERSEPROXY__CLUSTERS__cluster_ring__DESTINATIONS__destination1__ADDRESS: http://_http.ring
REVERSEPROXY__CLUSTERS__cluster_pass__DESTINATIONS__destination1__ADDRESS: http://_http.pass
REVERSEPROXY__CLUSTERS__cluster_padlock__DESTINATIONS__destination1__ADDRESS: http://_http.padlock
REVERSEPROXY__CLUSTERS__cluster_drive__DESTINATIONS__destination1__ADDRESS: http://_http.drive
REVERSEPROXY__CLUSTERS__cluster_sphere__DESTINATIONS__destination1__ADDRESS: http://_http.sphere
REVERSEPROXY__CLUSTERS__cluster_develop__DESTINATIONS__destination1__ADDRESS: http://_http.develop
services__pass__http__0: http://pass:8080
services__pass__grpc__0: https://pass:5001
services__padlock__http__0: http://padlock:8080
services__padlock__grpc__0: https://padlock:5001
services__drive__http__0: http://drive:8080
services__drive__grpc__0: https://drive:5001
services__sphere__http__0: http://sphere:8080