♻️ Refactored auth service for better security
This commit is contained in:
@@ -571,14 +571,14 @@ public class AccountCurrentController(
|
||||
.ToListAsync();
|
||||
|
||||
var challengeDevices = devices.Select(SnAuthClientWithChallenge.FromClient).ToList();
|
||||
var deviceIds = challengeDevices.Select(x => x.Id).ToList();
|
||||
var deviceIds = challengeDevices.Select(x => x.DeviceId).ToList();
|
||||
|
||||
var authChallenges = await db.AuthChallenges
|
||||
.Where(c => c.ClientId != null && deviceIds.Contains(c.ClientId.Value))
|
||||
.GroupBy(c => c.ClientId)
|
||||
.ToDictionaryAsync(c => c.Key!.Value, c => c.ToList());
|
||||
.Where(c => deviceIds.Contains(c.DeviceId))
|
||||
.GroupBy(c => c.DeviceId)
|
||||
.ToDictionaryAsync(c => c.Key, c => c.ToList());
|
||||
foreach (var challengeDevice in challengeDevices)
|
||||
if (authChallenges.TryGetValue(challengeDevice.Id, out var challenge))
|
||||
if (authChallenges.TryGetValue(challengeDevice.DeviceId, out var challenge))
|
||||
challengeDevice.Challenges = challenge;
|
||||
|
||||
return Ok(challengeDevices);
|
||||
@@ -688,7 +688,7 @@ public class AccountCurrentController(
|
||||
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.Challenge.ClientId);
|
||||
var device = await db.AuthClients.FirstOrDefaultAsync(d => d.Id == currentSession.ClientId);
|
||||
if (device is null) return NotFound();
|
||||
|
||||
try
|
||||
|
||||
@@ -508,9 +508,7 @@ public class AccountService(
|
||||
|
||||
private async Task<bool> IsDeviceActive(Guid id)
|
||||
{
|
||||
return await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.AnyAsync(s => s.Challenge.ClientId == id);
|
||||
return await db.AuthSessions.AnyAsync(s => s.ClientId == id);
|
||||
}
|
||||
|
||||
public async Task<SnAuthClient> UpdateDeviceName(SnAccount account, string deviceId, string label)
|
||||
@@ -529,8 +527,7 @@ public class AccountService(
|
||||
public async Task DeleteSession(SnAccount account, Guid sessionId)
|
||||
{
|
||||
var session = await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.ThenInclude(s => s.Client)
|
||||
.Include(s => s.Client)
|
||||
.Where(s => s.Id == sessionId && s.AccountId == account.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (session is null) throw new InvalidOperationException("Session was not found.");
|
||||
@@ -539,11 +536,11 @@ public class AccountService(
|
||||
db.AuthSessions.Remove(session);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (session.Challenge.ClientId.HasValue)
|
||||
if (session.ClientId.HasValue)
|
||||
{
|
||||
if (!await IsDeviceActive(session.Challenge.ClientId.Value))
|
||||
if (!await IsDeviceActive(session.ClientId.Value))
|
||||
await pusher.UnsubscribePushNotificationsAsync(new UnsubscribePushNotificationsRequest()
|
||||
{ DeviceId = session.Challenge.Client!.DeviceId }
|
||||
{ DeviceId = session.Client!.DeviceId }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -564,15 +561,13 @@ public class AccountService(
|
||||
);
|
||||
|
||||
var sessions = await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.Where(s => s.Challenge.ClientId == device.Id && s.AccountId == account.Id)
|
||||
.Where(s => s.ClientId == device.Id && s.AccountId == account.Id)
|
||||
.ToListAsync();
|
||||
|
||||
// The current session should be included in the sessions' list
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
await db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.Where(s => s.Challenge.ClientId == device.Id)
|
||||
.Where(s => s.ClientId == device.Id)
|
||||
.ExecuteUpdateAsync(p => p.SetProperty(s => s.DeletedAt, s => now));
|
||||
|
||||
db.AuthClients.Remove(device);
|
||||
|
||||
@@ -30,7 +30,7 @@ public class AuthController(
|
||||
|
||||
public class ChallengeRequest
|
||||
{
|
||||
[Required] public ClientPlatform Platform { get; set; }
|
||||
[Required] public Shared.Models.ClientPlatform Platform { get; set; }
|
||||
[Required] [MaxLength(256)] public string Account { get; set; } = null!;
|
||||
[Required] [MaxLength(512)] public string DeviceId { get; set; } = null!;
|
||||
[MaxLength(1024)] public string? DeviceName { get; set; }
|
||||
@@ -61,9 +61,6 @@ public class AuthController(
|
||||
|
||||
request.DeviceName ??= userAgent;
|
||||
|
||||
var device =
|
||||
await auth.GetOrCreateDeviceAsync(account.Id, request.DeviceId, request.DeviceName, request.Platform);
|
||||
|
||||
// Trying to pick up challenges from the same IP address and user agent
|
||||
var existingChallenge = await db.AuthChallenges
|
||||
.Where(e => e.AccountId == account.Id)
|
||||
@@ -72,7 +69,7 @@ public class AuthController(
|
||||
.Where(e => e.StepRemain > 0)
|
||||
.Where(e => e.ExpiredAt != null && now < e.ExpiredAt)
|
||||
.Where(e => e.Type == Shared.Models.ChallengeType.Login)
|
||||
.Where(e => e.ClientId == device.Id)
|
||||
.Where(e => e.DeviceId == request.DeviceId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (existingChallenge is not null)
|
||||
{
|
||||
@@ -90,7 +87,9 @@ public class AuthController(
|
||||
IpAddress = ipAddress,
|
||||
UserAgent = userAgent,
|
||||
Location = geo.GetPointFromIp(ipAddress),
|
||||
ClientId = device.Id,
|
||||
DeviceId = request.DeviceId,
|
||||
DeviceName = request.DeviceName,
|
||||
Platform = request.Platform,
|
||||
AccountId = account.Id
|
||||
}.Normalize();
|
||||
|
||||
@@ -176,7 +175,6 @@ public class AuthController(
|
||||
{
|
||||
var challenge = await db.AuthChallenges
|
||||
.Include(e => e.Account)
|
||||
.Include(authChallenge => authChallenge.Client)
|
||||
.FirstOrDefaultAsync(e => e.Id == id);
|
||||
if (challenge is null) return NotFound("Auth challenge was not found.");
|
||||
|
||||
@@ -246,7 +244,7 @@ public class AuthController(
|
||||
{
|
||||
Topic = "auth.login",
|
||||
Title = localizer["NewLoginTitle"],
|
||||
Body = localizer["NewLoginBody", challenge.Client?.DeviceName ?? "unknown",
|
||||
Body = localizer["NewLoginBody", challenge.DeviceName ?? "unknown",
|
||||
challenge.IpAddress ?? "unknown"],
|
||||
IsSavable = true
|
||||
},
|
||||
|
||||
@@ -157,7 +157,7 @@ public class AuthService(
|
||||
// 8) Device Trust Assessment
|
||||
var trustedDeviceIds = recentSessions
|
||||
.Where(s => s.CreatedAt > now.Minus(Duration.FromDays(30))) // Trust devices from last 30 days
|
||||
.Select(s => s.Challenge?.ClientId)
|
||||
.Select(s => s.ClientId)
|
||||
.Where(id => id.HasValue)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
@@ -182,7 +182,7 @@ public class AuthService(
|
||||
}
|
||||
|
||||
public async Task<SnAuthSession> CreateSessionForOidcAsync(SnAccount account, Instant time,
|
||||
Guid? customAppId = null)
|
||||
Guid? customAppId = null, SnAuthSession? parentSession = null)
|
||||
{
|
||||
var challenge = new SnAuthChallenge
|
||||
{
|
||||
@@ -191,7 +191,10 @@ public class AuthService(
|
||||
UserAgent = HttpContext.Request.Headers.UserAgent,
|
||||
StepRemain = 1,
|
||||
StepTotal = 1,
|
||||
Type = customAppId is not null ? ChallengeType.OAuth : ChallengeType.Oidc
|
||||
Type = customAppId is not null ? ChallengeType.OAuth : ChallengeType.Oidc,
|
||||
DeviceId = Guid.NewGuid().ToString(),
|
||||
DeviceName = "OIDC/OAuth",
|
||||
Platform = ClientPlatform.Web,
|
||||
};
|
||||
|
||||
var session = new SnAuthSession
|
||||
@@ -200,7 +203,8 @@ public class AuthService(
|
||||
CreatedAt = time,
|
||||
LastGrantedAt = time,
|
||||
Challenge = challenge,
|
||||
AppId = customAppId
|
||||
AppId = customAppId,
|
||||
ParentSessionId = parentSession?.Id
|
||||
};
|
||||
|
||||
db.AuthChallenges.Add(challenge);
|
||||
@@ -288,35 +292,75 @@ public class AuthService(
|
||||
|
||||
/// <summary>
|
||||
/// Immediately revoke a session by setting expiry to now and clearing from cache
|
||||
/// This provides immediate invalidation of tokens and sessions
|
||||
/// This provides immediate invalidation of tokens and sessions, including all child sessions recursively.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">Session ID to revoke</param>
|
||||
/// <returns>True if session was found and revoked, false otherwise</returns>
|
||||
public async Task<bool> RevokeSessionAsync(Guid sessionId)
|
||||
{
|
||||
var session = await db.AuthSessions.FirstOrDefaultAsync(s => s.Id == sessionId);
|
||||
if (session == null)
|
||||
var sessionsToRevokeIds = new HashSet<Guid>();
|
||||
await CollectSessionsToRevoke(sessionId, sessionsToRevokeIds);
|
||||
|
||||
if (sessionsToRevokeIds.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Set expiry to now (immediate invalidation)
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
session.ExpiredAt = now;
|
||||
db.AuthSessions.Update(session);
|
||||
var accountIdsToClearCache = new HashSet<Guid>();
|
||||
|
||||
// Clear from cache immediately
|
||||
var cacheKey = $"{AuthCachePrefix}{session.Id}";
|
||||
await cache.RemoveAsync(cacheKey);
|
||||
// Fetch all sessions to be revoked in one go
|
||||
var sessions = await db.AuthSessions
|
||||
.Where(s => sessionsToRevokeIds.Contains(s.Id))
|
||||
.ToListAsync();
|
||||
|
||||
// Clear account-level cache groups that include this session
|
||||
await cache.RemoveAsync($"{AuthCachePrefix}{session.AccountId}");
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
session.ExpiredAt = now;
|
||||
accountIdsToClearCache.Add(session.AccountId);
|
||||
|
||||
// Clear from cache immediately for each session
|
||||
await cache.RemoveAsync($"{AuthCachePrefix}{session.Id}");
|
||||
}
|
||||
|
||||
db.AuthSessions.UpdateRange(sessions);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Clear account-level cache groups
|
||||
foreach (var accountId in accountIdsToClearCache)
|
||||
{
|
||||
await cache.RemoveAsync($"{AuthCachePrefix}{accountId}");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively collects all session IDs that need to be revoked, starting from a given session.
|
||||
/// </summary>
|
||||
/// <param name="currentSessionId">The session ID to start collecting from.</param>
|
||||
/// <param name="sessionsToRevoke">A HashSet to store the IDs of all sessions to be revoked.</param>
|
||||
private async Task CollectSessionsToRevoke(Guid currentSessionId, HashSet<Guid> sessionsToRevoke)
|
||||
{
|
||||
if (sessionsToRevoke.Contains(currentSessionId))
|
||||
{
|
||||
return; // Already processed this session
|
||||
}
|
||||
|
||||
sessionsToRevoke.Add(currentSessionId);
|
||||
|
||||
// Find direct children
|
||||
var childSessions = await db.AuthSessions
|
||||
.Where(s => s.ParentSessionId == currentSessionId)
|
||||
.Select(s => s.Id)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var childId in childSessions)
|
||||
{
|
||||
await CollectSessionsToRevoke(childId, sessionsToRevoke);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revoke all sessions for an account (logout everywhere)
|
||||
/// </summary>
|
||||
@@ -380,13 +424,17 @@ public class AuthService(
|
||||
if (hasSession)
|
||||
throw new ArgumentException("Session already exists for this challenge.");
|
||||
|
||||
var device = await GetOrCreateDeviceAsync(challenge.AccountId, challenge.DeviceId, challenge.DeviceName,
|
||||
challenge.Platform);
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var session = new SnAuthSession
|
||||
{
|
||||
LastGrantedAt = now,
|
||||
ExpiredAt = now.Plus(Duration.FromDays(7)),
|
||||
AccountId = challenge.AccountId,
|
||||
ChallengeId = challenge.Id
|
||||
ChallengeId = challenge.Id,
|
||||
ClientId = device.Id,
|
||||
};
|
||||
|
||||
db.AuthSessions.Add(session);
|
||||
@@ -500,7 +548,7 @@ public class AuthService(
|
||||
return key;
|
||||
}
|
||||
|
||||
public async Task<SnApiKey> CreateApiKey(Guid accountId, string label, Instant? expiredAt = null)
|
||||
public async Task<SnApiKey> CreateApiKey(Guid accountId, string label, Instant? expiredAt = null, SnAuthSession? parentSession = null)
|
||||
{
|
||||
var key = new SnApiKey
|
||||
{
|
||||
@@ -509,7 +557,8 @@ public class AuthService(
|
||||
Session = new SnAuthSession
|
||||
{
|
||||
AccountId = accountId,
|
||||
ExpiredAt = expiredAt
|
||||
ExpiredAt = expiredAt,
|
||||
ParentSessionId = parentSession?.Id
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -342,13 +342,19 @@ public class ConnectionController(
|
||||
callbackData.State.Split('|').FirstOrDefault() :
|
||||
string.Empty;
|
||||
|
||||
var challenge = await oidcService.CreateChallengeForUserAsync(
|
||||
if (HttpContext.Items["CurrentSession"] is not SnAuthSession parentSession) parentSession = null;
|
||||
|
||||
var session = await oidcService.CreateSessionForUserAsync(
|
||||
userInfo,
|
||||
connection.Account,
|
||||
HttpContext,
|
||||
deviceId ?? string.Empty);
|
||||
deviceId ?? string.Empty,
|
||||
null,
|
||||
ClientPlatform.Web,
|
||||
parentSession);
|
||||
|
||||
var redirectUrl = QueryHelpers.AddQueryString(redirectBaseUrl, "challenge", challenge.Id.ToString());
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ public class OidcController(
|
||||
IServiceProvider serviceProvider,
|
||||
AppDatabase db,
|
||||
AccountService accounts,
|
||||
AuthService auth,
|
||||
ICacheService cache,
|
||||
ILogger<OidcController> logger
|
||||
)
|
||||
@@ -22,6 +23,11 @@ public class OidcController(
|
||||
private const string StateCachePrefix = "oidc-state:";
|
||||
private static readonly TimeSpan StateExpiration = TimeSpan.FromMinutes(15);
|
||||
|
||||
public class TokenExchangeResponse
|
||||
{
|
||||
public string Token { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[HttpGet("{provider}")]
|
||||
public async Task<ActionResult> OidcLogin(
|
||||
[FromRoute] string provider,
|
||||
@@ -75,7 +81,7 @@ public class OidcController(
|
||||
/// Handles Apple authentication directly from mobile apps
|
||||
/// </summary>
|
||||
[HttpPost("apple/mobile")]
|
||||
public async Task<ActionResult<SnAuthChallenge>> AppleMobileLogin(
|
||||
public async Task<ActionResult<TokenExchangeResponse>> AppleMobileLogin(
|
||||
[FromBody] AppleMobileSignInRequest request
|
||||
)
|
||||
{
|
||||
@@ -98,16 +104,21 @@ public class OidcController(
|
||||
// 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 challenge = await appleService.CreateChallengeForUserAsync(
|
||||
var session = await appleService.CreateSessionForUserAsync(
|
||||
userInfo,
|
||||
account,
|
||||
HttpContext,
|
||||
request.DeviceId,
|
||||
request.DeviceName
|
||||
request.DeviceName,
|
||||
ClientPlatform.Ios,
|
||||
parentSession
|
||||
);
|
||||
|
||||
return Ok(challenge);
|
||||
var token = auth.CreateToken(session);
|
||||
return Ok(new TokenExchangeResponse { Token = token });
|
||||
}
|
||||
catch (SecurityTokenValidationException ex)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@@ -250,15 +249,17 @@ public abstract class OidcService(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a challenge and session for an authenticated user
|
||||
/// Creates a session for an authenticated user
|
||||
/// Also creates or updates the account connection
|
||||
/// </summary>
|
||||
public async Task<SnAuthChallenge> CreateChallengeForUserAsync(
|
||||
public async Task<SnAuthSession> CreateSessionForUserAsync(
|
||||
OidcUserInfo userInfo,
|
||||
SnAccount account,
|
||||
HttpContext request,
|
||||
string deviceId,
|
||||
string? deviceName = null
|
||||
string? deviceName = null,
|
||||
ClientPlatform platform = ClientPlatform.Web,
|
||||
SnAuthSession? parentSession = null
|
||||
)
|
||||
{
|
||||
// Create or update the account connection
|
||||
@@ -282,28 +283,24 @@ public abstract class OidcService(
|
||||
await Db.AccountConnections.AddAsync(connection);
|
||||
}
|
||||
|
||||
// Create a challenge that's already completed
|
||||
// Create a session directly
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var device = await auth.GetOrCreateDeviceAsync(account.Id, deviceId, deviceName, ClientPlatform.Ios);
|
||||
var challenge = new SnAuthChallenge
|
||||
{
|
||||
ExpiredAt = now.Plus(Duration.FromHours(1)),
|
||||
StepTotal = await auth.DetectChallengeRisk(request.Request, account),
|
||||
Type = ChallengeType.Oidc,
|
||||
Audiences = [ProviderName],
|
||||
Scopes = ["*"],
|
||||
AccountId = account.Id,
|
||||
ClientId = device.Id,
|
||||
IpAddress = request.Connection.RemoteIpAddress?.ToString() ?? null,
|
||||
UserAgent = request.Request.Headers.UserAgent,
|
||||
};
|
||||
challenge.StepRemain--;
|
||||
if (challenge.StepRemain < 0) challenge.StepRemain = 0;
|
||||
var device = await auth.GetOrCreateDeviceAsync(account.Id, deviceId, deviceName, platform);
|
||||
|
||||
await Db.AuthChallenges.AddAsync(challenge);
|
||||
var session = new SnAuthSession
|
||||
{
|
||||
AccountId = account.Id,
|
||||
CreatedAt = now,
|
||||
LastGrantedAt = now,
|
||||
ParentSessionId = parentSession?.Id,
|
||||
ClientId = device.Id,
|
||||
ExpiredAt = now.Plus(Duration.FromDays(30))
|
||||
};
|
||||
|
||||
await Db.AuthSessions.AddAsync(session);
|
||||
await Db.SaveChangesAsync();
|
||||
|
||||
return challenge;
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ public class TokenAuthService(
|
||||
session = await db.AuthSessions
|
||||
.AsNoTracking()
|
||||
.Include(e => e.Challenge)
|
||||
.ThenInclude(e => e.Client)
|
||||
.Include(e => e.Client)
|
||||
.Include(e => e.Account)
|
||||
.ThenInclude(e => e.Profile)
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId);
|
||||
@@ -110,7 +110,7 @@ public class TokenAuthService(
|
||||
"AuthenticateTokenAsync: DB session loaded (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId}, appId={AppId}, scopes={ScopeCount}, ip={Ip}, uaLen={UaLen})",
|
||||
sessionId,
|
||||
session.AccountId,
|
||||
session.Challenge?.ClientId,
|
||||
session.ClientId,
|
||||
session.AppId,
|
||||
session.Challenge?.Scopes.Count,
|
||||
session.Challenge?.IpAddress,
|
||||
@@ -143,7 +143,7 @@ public class TokenAuthService(
|
||||
"AuthenticateTokenAsync: success via DB (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId})",
|
||||
sessionId,
|
||||
session.AccountId,
|
||||
session.Challenge?.ClientId
|
||||
session.ClientId
|
||||
);
|
||||
return (true, session, null);
|
||||
}
|
||||
|
||||
2755
DysonNetwork.Pass/Migrations/20251129095046_DecoupleAuthSessionAndChallenge.Designer.cs
generated
Normal file
2755
DysonNetwork.Pass/Migrations/20251129095046_DecoupleAuthSessionAndChallenge.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,143 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Pass.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class DecoupleAuthSessionAndChallenge : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_auth_challenges_auth_clients_client_id",
|
||||
table: "auth_challenges");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_auth_challenges_client_id",
|
||||
table: "auth_challenges");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "client_id",
|
||||
table: "auth_challenges");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "client_id",
|
||||
table: "auth_sessions",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "parent_session_id",
|
||||
table: "auth_sessions",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "device_id",
|
||||
table: "auth_challenges",
|
||||
type: "character varying(512)",
|
||||
maxLength: 512,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "device_name",
|
||||
table: "auth_challenges",
|
||||
type: "character varying(1024)",
|
||||
maxLength: 1024,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "platform",
|
||||
table: "auth_challenges",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
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.AddForeignKey(
|
||||
name: "fk_auth_sessions_auth_clients_client_id",
|
||||
table: "auth_sessions",
|
||||
column: "client_id",
|
||||
principalTable: "auth_clients",
|
||||
principalColumn: "id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_auth_sessions_auth_sessions_parent_session_id",
|
||||
table: "auth_sessions",
|
||||
column: "parent_session_id",
|
||||
principalTable: "auth_sessions",
|
||||
principalColumn: "id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_auth_sessions_auth_clients_client_id",
|
||||
table: "auth_sessions");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_auth_sessions_auth_sessions_parent_session_id",
|
||||
table: "auth_sessions");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_auth_sessions_client_id",
|
||||
table: "auth_sessions");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_auth_sessions_parent_session_id",
|
||||
table: "auth_sessions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "client_id",
|
||||
table: "auth_sessions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "parent_session_id",
|
||||
table: "auth_sessions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "device_id",
|
||||
table: "auth_challenges");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "device_name",
|
||||
table: "auth_challenges");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "platform",
|
||||
table: "auth_challenges");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "client_id",
|
||||
table: "auth_challenges",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_auth_challenges_client_id",
|
||||
table: "auth_challenges",
|
||||
column: "client_id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_auth_challenges_auth_clients_client_id",
|
||||
table: "auth_challenges",
|
||||
column: "client_id",
|
||||
principalTable: "auth_clients",
|
||||
principalColumn: "id");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -778,10 +778,6 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("blacklist_factors");
|
||||
|
||||
b.Property<Guid?>("ClientId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("client_id");
|
||||
|
||||
b.Property<Instant>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
@@ -790,6 +786,17 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.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");
|
||||
@@ -812,6 +819,10 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasColumnName("nonce");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("platform");
|
||||
|
||||
b.Property<List<string>>("Scopes")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
@@ -844,9 +855,6 @@ namespace DysonNetwork.Pass.Migrations
|
||||
b.HasIndex("AccountId")
|
||||
.HasDatabaseName("ix_auth_challenges_account_id");
|
||||
|
||||
b.HasIndex("ClientId")
|
||||
.HasDatabaseName("ix_auth_challenges_client_id");
|
||||
|
||||
b.ToTable("auth_challenges", (string)null);
|
||||
});
|
||||
|
||||
@@ -922,6 +930,10 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.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");
|
||||
@@ -938,6 +950,10 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_granted_at");
|
||||
|
||||
b.Property<Guid?>("ParentSessionId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("parent_session_id");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
@@ -951,6 +967,12 @@ namespace DysonNetwork.Pass.Migrations
|
||||
b.HasIndex("ChallengeId")
|
||||
.HasDatabaseName("ix_auth_sessions_challenge_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);
|
||||
});
|
||||
|
||||
@@ -2374,14 +2396,7 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_auth_challenges_accounts_account_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Shared.Models.SnAuthClient", "Client")
|
||||
.WithMany()
|
||||
.HasForeignKey("ClientId")
|
||||
.HasConstraintName("fk_auth_challenges_auth_clients_client_id");
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("Client");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnAuthClient", b =>
|
||||
@@ -2410,9 +2425,23 @@ namespace DysonNetwork.Pass.Migrations
|
||||
.HasForeignKey("ChallengeId")
|
||||
.HasConstraintName("fk_auth_sessions_auth_challenges_challenge_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("Challenge");
|
||||
|
||||
b.Navigation("Client");
|
||||
|
||||
b.Navigation("ParentSession");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Shared.Models.SnCheckInResult", b =>
|
||||
|
||||
@@ -16,10 +16,18 @@ public class SnAuthSession : ModelBase
|
||||
public Guid AccountId { get; set; }
|
||||
[JsonIgnore] public SnAccount Account { get; set; } = null!;
|
||||
|
||||
// When the challenge is null, indicates the session is for an API Key
|
||||
// The challenge that created this session
|
||||
public Guid? ChallengeId { get; set; }
|
||||
public SnAuthChallenge? Challenge { get; set; } = null!;
|
||||
|
||||
// The client device for this session
|
||||
public Guid? ClientId { get; set; }
|
||||
public SnAuthClient? Client { get; set; } = null!;
|
||||
|
||||
// For sub-sessions (e.g. OAuth)
|
||||
public Guid? ParentSessionId { get; set; }
|
||||
public SnAuthSession? ParentSession { get; set; }
|
||||
|
||||
// Indicates the session is for an OIDC connection
|
||||
public Guid? AppId { get; set; }
|
||||
|
||||
@@ -32,6 +40,9 @@ public class SnAuthSession : ModelBase
|
||||
Account = Account.ToProtoValue(),
|
||||
ChallengeId = ChallengeId.ToString(),
|
||||
Challenge = Challenge?.ToProtoValue(),
|
||||
ClientId = ClientId.ToString(),
|
||||
Client = Client?.ToProtoValue(),
|
||||
ParentSessionId = ParentSessionId.ToString(),
|
||||
AppId = AppId?.ToString()
|
||||
};
|
||||
}
|
||||
@@ -67,13 +78,14 @@ public class SnAuthChallenge : ModelBase
|
||||
[Column(TypeName = "jsonb")] public List<string> Scopes { get; set; } = new();
|
||||
[MaxLength(128)] public string? IpAddress { get; set; }
|
||||
[MaxLength(512)] public string? UserAgent { get; set; }
|
||||
[MaxLength(512)] public string DeviceId { get; set; } = null!;
|
||||
[MaxLength(1024)] public string? DeviceName { get; set; }
|
||||
public ClientPlatform Platform { get; set; }
|
||||
[MaxLength(1024)] public string? Nonce { get; set; }
|
||||
[Column(TypeName = "jsonb")] public GeoPoint? Location { get; set; }
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
[JsonIgnore] public SnAccount Account { get; set; } = null!;
|
||||
public Guid? ClientId { get; set; }
|
||||
public SnAuthClient? Client { get; set; } = null!;
|
||||
|
||||
public SnAuthChallenge Normalize()
|
||||
{
|
||||
@@ -94,7 +106,7 @@ public class SnAuthChallenge : ModelBase
|
||||
Scopes = { Scopes },
|
||||
IpAddress = IpAddress,
|
||||
UserAgent = UserAgent,
|
||||
DeviceId = Client!.DeviceId,
|
||||
DeviceId = DeviceId,
|
||||
Nonce = Nonce,
|
||||
AccountId = AccountId.ToString()
|
||||
};
|
||||
@@ -110,6 +122,16 @@ public class SnAuthClient : ModelBase
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
[JsonIgnore] public SnAccount Account { get; set; } = null!;
|
||||
|
||||
public Proto.AuthClient ToProtoValue() => new()
|
||||
{
|
||||
Id = Id.ToString(),
|
||||
Platform = (Proto.ClientPlatform)Platform,
|
||||
DeviceName = DeviceName,
|
||||
DeviceLabel = DeviceLabel,
|
||||
DeviceId = DeviceId,
|
||||
AccountId = AccountId.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
public class SnAuthClientWithChallenge : SnAuthClient
|
||||
|
||||
@@ -8,7 +8,7 @@ import "google/protobuf/timestamp.proto";
|
||||
import "google/protobuf/wrappers.proto";
|
||||
import "google/protobuf/struct.proto";
|
||||
|
||||
import 'account.proto';
|
||||
import "account.proto";
|
||||
|
||||
// Represents a user session
|
||||
message AuthSession {
|
||||
@@ -20,6 +20,9 @@ message AuthSession {
|
||||
string challenge_id = 7;
|
||||
AuthChallenge challenge = 8;
|
||||
google.protobuf.StringValue app_id = 9;
|
||||
optional string client_id = 10;
|
||||
optional string parent_session_id = 11;
|
||||
AuthClient client = 12;
|
||||
}
|
||||
|
||||
// Represents an authentication challenge
|
||||
@@ -39,6 +42,17 @@ message AuthChallenge {
|
||||
google.protobuf.StringValue nonce = 14;
|
||||
// Point location is omitted as there is no direct proto equivalent.
|
||||
string account_id = 15;
|
||||
google.protobuf.StringValue device_name = 16;
|
||||
ClientPlatform platform = 17;
|
||||
}
|
||||
|
||||
message AuthClient {
|
||||
string id = 1;
|
||||
ClientPlatform platform = 2;
|
||||
google.protobuf.StringValue device_name = 3;
|
||||
google.protobuf.StringValue device_label = 4;
|
||||
string device_id = 5;
|
||||
string account_id = 6;
|
||||
}
|
||||
|
||||
// Enum for challenge types
|
||||
@@ -49,9 +63,9 @@ enum ChallengeType {
|
||||
OIDC = 3;
|
||||
}
|
||||
|
||||
// Enum for challenge platforms
|
||||
enum ChallengePlatform {
|
||||
CHALLENGE_PLATFORM_UNSPECIFIED = 0;
|
||||
// Enum for client platforms
|
||||
enum ClientPlatform {
|
||||
CLIENT_PLATFORM_UNSPECIFIED = 0;
|
||||
UNIDENTIFIED = 1;
|
||||
WEB = 2;
|
||||
IOS = 3;
|
||||
@@ -184,6 +198,7 @@ service PermissionService {
|
||||
rpc AddPermissionNode(AddPermissionNodeRequest) returns (AddPermissionNodeResponse) {}
|
||||
rpc AddPermissionNodeToGroup(AddPermissionNodeToGroupRequest) returns (AddPermissionNodeToGroupResponse) {}
|
||||
rpc RemovePermissionNode(RemovePermissionNodeRequest) returns (RemovePermissionNodeResponse) {}
|
||||
rpc RemovePermissionNodeFromGroup(RemovePermissionNodeFromGroupRequest) returns (RemovePermissionNodeFromGroupResponse) {}
|
||||
rpc RemovePermissionNodeFromGroup(RemovePermissionNodeFromGroupRequest)
|
||||
returns (RemovePermissionNodeFromGroupResponse) {}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user