♻️ Move auth logic from pass to padlock
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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" />
|
||||
|
||||
105
DysonNetwork.Padlock/Account/AccountController.cs
Normal file
105
DysonNetwork.Padlock/Account/AccountController.cs
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
276
DysonNetwork.Padlock/Account/AccountSecurityController.cs
Normal file
276
DysonNetwork.Padlock/Account/AccountSecurityController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
333
DysonNetwork.Padlock/Account/AccountService.cs
Normal file
333
DysonNetwork.Padlock/Account/AccountService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>()
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
19
DysonNetwork.Padlock/Auth/OpenId/AppleMobileSignInRequest.cs
Normal file
19
DysonNetwork.Padlock/Auth/OpenId/AppleMobileSignInRequest.cs
Normal 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; }
|
||||
}
|
||||
425
DysonNetwork.Padlock/Auth/OpenId/ConnectionController.cs
Normal file
425
DysonNetwork.Padlock/Auth/OpenId/ConnectionController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
212
DysonNetwork.Padlock/Auth/OpenId/OidcController.cs
Normal file
212
DysonNetwork.Padlock/Auth/OpenId/OidcController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
190
DysonNetwork.Padlock/Auth/OpenId/OidcState.cs
Normal file
190
DysonNetwork.Padlock/Auth/OpenId/OidcState.cs
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
913
DysonNetwork.Padlock/Migrations/20260306142133_InitialPadlockAuthSplit.Designer.cs
generated
Normal file
913
DysonNetwork.Padlock/Migrations/20260306142133_InitialPadlockAuthSplit.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
910
DysonNetwork.Padlock/Migrations/AppDatabaseModelSnapshot.cs
Normal file
910
DysonNetwork.Padlock/Migrations/AppDatabaseModelSnapshot.cs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
32
DysonNetwork.Padlock/Startup/ApplicationConfiguration.cs
Normal file
32
DysonNetwork.Padlock/Startup/ApplicationConfiguration.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
54
build/migrate-pass-auth-to-padlock.sh
Executable file
54
build/migrate-pass-auth-to-padlock.sh
Executable 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."
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user