Compare commits
18 Commits
5f7ab49abb
...
master
Author | SHA1 | Date | |
---|---|---|---|
|
fb6721cb1b | ||
|
9fcb169c94 | ||
|
572874431d | ||
|
f595ac8001 | ||
|
18674e0e1d | ||
|
da4c4d3a84 | ||
|
aec01b117d | ||
|
d299c32e35 | ||
|
344007af66 | ||
|
d4de5aeac2 | ||
|
8ce5ba50f4 | ||
|
5a44952b27 | ||
|
c30946daf6 | ||
|
0221d7b294 | ||
|
c44b0b64c3 | ||
|
442ee3bcfd | ||
|
081815c512 | ||
|
eab2a388ae |
@@ -337,8 +337,14 @@ public class FileService(
|
||||
if (!pool.PolicyConfig.NoOptimization)
|
||||
switch (contentType.Split('/')[0])
|
||||
{
|
||||
case "image" when !AnimatedImageTypes.Contains(contentType) &&
|
||||
!AnimatedImageExtensions.Contains(fileExtension):
|
||||
case "image":
|
||||
if (AnimatedImageTypes.Contains(contentType) || AnimatedImageExtensions.Contains(fileExtension))
|
||||
{
|
||||
logger.LogInformation("Skip optimize file {FileId} due to it is animated...", fileId);
|
||||
uploads.Add((originalFilePath, string.Empty, contentType, false));
|
||||
break;
|
||||
}
|
||||
|
||||
newMimeType = "image/webp";
|
||||
using (var vipsImage = Image.NewFromFile(originalFilePath))
|
||||
{
|
||||
@@ -672,8 +678,8 @@ public class FileService(
|
||||
foreach (var file in fileGroup)
|
||||
{
|
||||
objectsToDelete.Add(file.StorageId ?? file.Id);
|
||||
if(file.HasCompression) objectsToDelete.Add(file.StorageId ?? file.Id + ".compressed");
|
||||
if(file.HasThumbnail) objectsToDelete.Add(file.StorageId ?? file.Id + ".thumbnail");
|
||||
if (file.HasCompression) objectsToDelete.Add(file.StorageId ?? file.Id + ".compressed");
|
||||
if (file.HasThumbnail) objectsToDelete.Add(file.StorageId ?? file.Id + ".thumbnail");
|
||||
}
|
||||
|
||||
await client.RemoveObjectsAsync(
|
||||
|
@@ -76,26 +76,6 @@ public class RegistryProxyConfigProvider : IProxyConfigProvider, IDisposable
|
||||
|
||||
var gatewayServiceName = _configuration["Service:Name"];
|
||||
|
||||
// Add direct route for /cgi to Gateway
|
||||
var gatewayCluster = new ClusterConfig
|
||||
{
|
||||
ClusterId = "gateway-self",
|
||||
Destinations = new Dictionary<string, DestinationConfig>
|
||||
{
|
||||
{ "self", new DestinationConfig { Address = _configuration["Kestrel:Endpoints:Http:Url"] ?? "http://localhost:5000" } }
|
||||
}
|
||||
};
|
||||
clusters.Add(gatewayCluster);
|
||||
|
||||
var cgiRoute = new RouteConfig
|
||||
{
|
||||
RouteId = "gateway-cgi-route",
|
||||
ClusterId = "gateway-self",
|
||||
Match = new RouteMatch { Path = "/cgi/{**catch-all}" }
|
||||
};
|
||||
routes.Add(cgiRoute);
|
||||
_logger.LogInformation(" Added CGI Route: /cgi/** -> Gateway");
|
||||
|
||||
// Add direct routes
|
||||
foreach (var directRoute in directRoutes)
|
||||
{
|
||||
|
@@ -49,7 +49,10 @@ public class DysonTokenAuthHandler(
|
||||
|
||||
try
|
||||
{
|
||||
var (valid, session, message) = await token.AuthenticateTokenAsync(tokenInfo.Token);
|
||||
// Get client IP address
|
||||
var ipAddress = Context.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
var (valid, session, message) = await token.AuthenticateTokenAsync(tokenInfo.Token, ipAddress);
|
||||
if (!valid || session is null)
|
||||
return AuthenticateResult.Fail(message ?? "Authentication failed.");
|
||||
|
||||
@@ -67,7 +70,7 @@ public class DysonTokenAuthHandler(
|
||||
};
|
||||
|
||||
// Add scopes as claims
|
||||
session.Challenge.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope)));
|
||||
session.Challenge?.Scopes.ForEach(scope => claims.Add(new Claim("scope", scope)));
|
||||
|
||||
// Add superuser claim if applicable
|
||||
if (session.Account.IsSuperuser)
|
||||
|
@@ -51,7 +51,11 @@ public class AuthController(
|
||||
.Where(e => e.Type == PunishmentType.BlockLogin || e.Type == PunishmentType.DisableAccount)
|
||||
.Where(e => e.ExpiredAt == null || now < e.ExpiredAt)
|
||||
.FirstOrDefaultAsync();
|
||||
if (punishment is not null) return StatusCode(423, punishment);
|
||||
if (punishment is not null)
|
||||
return StatusCode(
|
||||
423,
|
||||
$"Your account has been suspended. Reason: {punishment.Reason}. Expired at: {punishment.ExpiredAt?.ToString() ?? "never"}"
|
||||
);
|
||||
|
||||
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
|
||||
|
@@ -52,7 +52,7 @@ public class AuthService(
|
||||
riskScore += 1;
|
||||
else
|
||||
{
|
||||
if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge.IpAddress) &&
|
||||
if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge?.IpAddress) &&
|
||||
!lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase))
|
||||
riskScore += 1;
|
||||
}
|
||||
|
@@ -1,9 +1,5 @@
|
||||
using DysonNetwork.Pass.Wallet;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Grpc.Core;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Pass.Auth;
|
||||
|
||||
@@ -18,7 +14,7 @@ public class AuthServiceGrpc(
|
||||
ServerCallContext context
|
||||
)
|
||||
{
|
||||
var (valid, session, message) = await token.AuthenticateTokenAsync(request.Token);
|
||||
var (valid, session, message) = await token.AuthenticateTokenAsync(request.Token, request.IpAddress);
|
||||
if (!valid || session is null)
|
||||
return new AuthenticateResponse { Valid = false, Message = message ?? "Authentication failed." };
|
||||
|
||||
|
@@ -126,7 +126,6 @@ public class OidcProviderController(
|
||||
[FromForm(Name = "redirect_uri")] string? redirectUri = null,
|
||||
[FromForm] string? scope = null,
|
||||
[FromForm] string? state = null,
|
||||
[FromForm(Name = "response_type")] string? responseType = null,
|
||||
[FromForm] string? nonce = null,
|
||||
[FromForm(Name = "code_challenge")] string? codeChallenge = null,
|
||||
[FromForm(Name = "code_challenge_method")]
|
||||
@@ -156,7 +155,7 @@ public class OidcProviderController(
|
||||
if (!string.IsNullOrEmpty(state)) queryParams["state"] = state;
|
||||
|
||||
errorUri.Query = queryParams.ToString();
|
||||
return Redirect(errorUri.Uri.ToString());
|
||||
return Ok(new { redirectUri = errorUri.Uri.ToString() });
|
||||
}
|
||||
|
||||
// Validate redirect_uri if provided
|
||||
@@ -191,7 +190,8 @@ public class OidcProviderController(
|
||||
scope?.Split(' ') ?? [],
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
nonce);
|
||||
nonce
|
||||
);
|
||||
|
||||
// Build the redirect URI with the authorization code
|
||||
var redirectBuilder = new UriBuilder(redirectUri);
|
||||
@@ -201,7 +201,7 @@ public class OidcProviderController(
|
||||
|
||||
redirectBuilder.Query = queryParams.ToString();
|
||||
|
||||
return Redirect(redirectBuilder.Uri.ToString());
|
||||
return Ok(new { redirectUri = redirectBuilder.Uri.ToString() });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -307,7 +307,7 @@ public class OidcProviderController(
|
||||
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
|
||||
|
||||
// Get requested scopes from the token
|
||||
var scopes = currentSession.Challenge.Scopes;
|
||||
var scopes = currentSession.Challenge?.Scopes ?? [];
|
||||
|
||||
var userInfo = new Dictionary<string, object>
|
||||
{
|
||||
|
@@ -20,7 +20,6 @@ public class TokenResponse
|
||||
[JsonPropertyName("scope")]
|
||||
public string? Scope { get; set; }
|
||||
|
||||
|
||||
[JsonPropertyName("id_token")]
|
||||
public string? IdToken { get; set; }
|
||||
}
|
||||
|
@@ -11,6 +11,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using NodaTime;
|
||||
using AccountContactType = DysonNetwork.Pass.Account.AccountContactType;
|
||||
|
||||
namespace DysonNetwork.Pass.Auth.OidcProvider.Services;
|
||||
|
||||
@@ -37,12 +38,21 @@ public class OidcProviderService(
|
||||
return resp.App ?? null;
|
||||
}
|
||||
|
||||
public async Task<AuthSession?> FindValidSessionAsync(Guid accountId, Guid clientId)
|
||||
public async Task<AuthSession?> FindValidSessionAsync(Guid accountId, Guid clientId, bool withAccount = false)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
return await db.AuthSessions
|
||||
var queryable = db.AuthSessions
|
||||
.Include(s => s.Challenge)
|
||||
.AsQueryable();
|
||||
if (withAccount)
|
||||
queryable = queryable
|
||||
.Include(s => s.Account)
|
||||
.ThenInclude(a => a.Profile)
|
||||
.Include(a => a.Account.Contacts)
|
||||
.AsQueryable();
|
||||
|
||||
return await queryable
|
||||
.Where(s => s.AccountId == accountId &&
|
||||
s.AppId == clientId &&
|
||||
(s.ExpiredAt == null || s.ExpiredAt > now) &&
|
||||
@@ -133,6 +143,79 @@ public class OidcProviderService(
|
||||
return false;
|
||||
}
|
||||
|
||||
private string GenerateIdToken(
|
||||
CustomApp client,
|
||||
AuthSession 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));
|
||||
if (!string.IsNullOrEmpty(session.Account.Profile.FirstName))
|
||||
claims.Add(new Claim("given_name", session.Account.Profile.FirstName));
|
||||
if (!string.IsNullOrEmpty(session.Account.Profile.LastName))
|
||||
claims.Add(new Claim("family_name", session.Account.Profile.LastName));
|
||||
}
|
||||
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity(claims),
|
||||
Issuer = _options.IssuerUri,
|
||||
Audience = client.Id.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);
|
||||
}
|
||||
|
||||
public async Task<TokenResponse> GenerateTokenResponseAsync(
|
||||
Guid clientId,
|
||||
string? authorizationCode = null,
|
||||
@@ -148,24 +231,43 @@ public class OidcProviderService(
|
||||
AuthSession session;
|
||||
var clock = SystemClock.Instance;
|
||||
var now = clock.GetCurrentInstant();
|
||||
|
||||
string? nonce = null;
|
||||
List<string>? scopes = null;
|
||||
|
||||
if (authorizationCode != null)
|
||||
{
|
||||
// Authorization code flow
|
||||
var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
|
||||
if (authCode is null) throw new InvalidOperationException("Invalid authorization code");
|
||||
var account = await db.Accounts.Where(a => a.Id == authCode.AccountId).FirstOrDefaultAsync();
|
||||
if (account is null) throw new InvalidOperationException("Account was not found");
|
||||
if (authCode == null)
|
||||
throw new InvalidOperationException("Invalid authorization code");
|
||||
|
||||
// Load the session for the user
|
||||
var existingSession = await FindValidSessionAsync(authCode.AccountId, clientId, withAccount: true);
|
||||
|
||||
if (existingSession is null)
|
||||
{
|
||||
var account = await db.Accounts
|
||||
.Where(a => a.Id == authCode.AccountId)
|
||||
.Include(a => a.Profile)
|
||||
.Include(a => a.Contacts)
|
||||
.FirstOrDefaultAsync();
|
||||
if (account is null) throw new InvalidOperationException("Account not found");
|
||||
session = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant(), clientId);
|
||||
session.Account = account;
|
||||
}
|
||||
else
|
||||
{
|
||||
session = existingSession;
|
||||
}
|
||||
|
||||
session = await auth.CreateSessionForOidcAsync(account, now, clientId);
|
||||
scopes = authCode.Scopes;
|
||||
nonce = authCode.Nonce;
|
||||
}
|
||||
else if (sessionId.HasValue)
|
||||
{
|
||||
// Refresh token flow
|
||||
session = await FindSessionByIdAsync(sessionId.Value) ??
|
||||
throw new InvalidOperationException("Invalid session");
|
||||
throw new InvalidOperationException("Session not found");
|
||||
|
||||
// Verify the session is still valid
|
||||
if (session.ExpiredAt < now)
|
||||
@@ -179,13 +281,15 @@ public class OidcProviderService(
|
||||
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
|
||||
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
|
||||
|
||||
// Generate an access token
|
||||
// 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,
|
||||
@@ -211,11 +315,10 @@ public class OidcProviderService(
|
||||
new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
|
||||
ClaimValueTypes.Integer64),
|
||||
new Claim("client_id", client.Id)
|
||||
]),
|
||||
Expires = expiresAt.ToDateTimeUtc(),
|
||||
Issuer = _options.IssuerUri,
|
||||
Audience = client.Id
|
||||
Audience = client.Slug
|
||||
};
|
||||
|
||||
// Try to use RSA signing if keys are available, fall back to HMAC
|
||||
@@ -281,51 +384,6 @@ public class OidcProviderService(
|
||||
return Convert.ToBase64String(session.Id.ToByteArray());
|
||||
}
|
||||
|
||||
private static bool VerifyHashedSecret(string secret, string hashedSecret)
|
||||
{
|
||||
// In a real implementation, you'd use a proper password hashing algorithm like PBKDF2, bcrypt, or Argon2
|
||||
// For now, we'll do a simple comparison, but you should replace this with proper hashing
|
||||
return string.Equals(secret, hashedSecret, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public async Task<string> GenerateAuthorizationCodeForReuseSessionAsync(
|
||||
AuthSession session,
|
||||
Guid clientId,
|
||||
string redirectUri,
|
||||
IEnumerable<string> scopes,
|
||||
string? codeChallenge = null,
|
||||
string? codeChallengeMethod = null,
|
||||
string? nonce = null)
|
||||
{
|
||||
var clock = SystemClock.Instance;
|
||||
var now = clock.GetCurrentInstant();
|
||||
var code = Guid.NewGuid().ToString("N");
|
||||
|
||||
// Update the session's last activity time
|
||||
await db.AuthSessions.Where(s => s.Id == session.Id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(s => s.LastGrantedAt, now));
|
||||
|
||||
// Create the authorization code info
|
||||
var authCodeInfo = new AuthorizationCodeInfo
|
||||
{
|
||||
ClientId = clientId,
|
||||
AccountId = session.AccountId,
|
||||
RedirectUri = redirectUri,
|
||||
Scopes = scopes.ToList(),
|
||||
CodeChallenge = codeChallenge,
|
||||
CodeChallengeMethod = codeChallengeMethod,
|
||||
Nonce = nonce,
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
// Store the code with its metadata in the cache
|
||||
var cacheKey = $"auth:code:{code}";
|
||||
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
|
||||
|
||||
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, session.AccountId);
|
||||
return code;
|
||||
}
|
||||
|
||||
public async Task<string> GenerateAuthorizationCodeAsync(
|
||||
Guid clientId,
|
||||
Guid userId,
|
||||
@@ -355,7 +413,7 @@ public class OidcProviderService(
|
||||
};
|
||||
|
||||
// Store the code with its metadata in the cache
|
||||
var cacheKey = $"auth:code:{code}";
|
||||
var cacheKey = $"auth:oidc-code:{code}";
|
||||
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
|
||||
|
||||
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId);
|
||||
@@ -369,7 +427,7 @@ public class OidcProviderService(
|
||||
string? codeVerifier = null
|
||||
)
|
||||
{
|
||||
var cacheKey = $"auth:code:{code}";
|
||||
var cacheKey = $"auth:oidc-code:{code}";
|
||||
var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey);
|
||||
|
||||
if (!found || authCode == null)
|
||||
|
@@ -1,3 +1,4 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using DysonNetwork.Pass.Wallet;
|
||||
@@ -22,8 +23,9 @@ public class TokenAuthService(
|
||||
/// then cache and return.
|
||||
/// </summary>
|
||||
/// <param name="token">Incoming token string</param>
|
||||
/// <param name="ipAddress">Client IP address, for logging purposes</param>
|
||||
/// <returns>(Valid, Session, Message)</returns>
|
||||
public async Task<(bool Valid, AuthSession? Session, string? Message)> AuthenticateTokenAsync(string token)
|
||||
public async Task<(bool Valid, AuthSession? Session, string? Message)> AuthenticateTokenAsync(string token, string? ipAddress = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -33,6 +35,11 @@ public class TokenAuthService(
|
||||
return (false, null, "No token provided.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(ipAddress))
|
||||
{
|
||||
logger.LogDebug("AuthenticateTokenAsync: client IP: {IpAddress}", ipAddress);
|
||||
}
|
||||
|
||||
// token fingerprint for correlation
|
||||
var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
||||
var tokenFp = tokenHash[..8];
|
||||
@@ -70,7 +77,7 @@ public class TokenAuthService(
|
||||
"AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})",
|
||||
sessionId,
|
||||
session.AccountId,
|
||||
session.Challenge.Scopes.Count,
|
||||
session.Challenge?.Scopes.Count,
|
||||
session.ExpiredAt
|
||||
);
|
||||
return (true, session, null);
|
||||
@@ -103,11 +110,11 @@ 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.Challenge?.ClientId,
|
||||
session.AppId,
|
||||
session.Challenge.Scopes.Count,
|
||||
session.Challenge.IpAddress,
|
||||
(session.Challenge.UserAgent ?? string.Empty).Length
|
||||
session.Challenge?.Scopes.Count,
|
||||
session.Challenge?.IpAddress,
|
||||
(session.Challenge?.UserAgent ?? string.Empty).Length
|
||||
);
|
||||
|
||||
logger.LogDebug("AuthenticateTokenAsync: enriching account with subscription (accountId={AccountId})", session.AccountId);
|
||||
@@ -136,7 +143,7 @@ public class TokenAuthService(
|
||||
"AuthenticateTokenAsync: success via DB (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId})",
|
||||
sessionId,
|
||||
session.AccountId,
|
||||
session.Challenge.ClientId
|
||||
session.Challenge?.ClientId
|
||||
);
|
||||
return (true, session, null);
|
||||
}
|
||||
|
@@ -169,15 +169,15 @@ function handleDeny() {
|
||||
error_description: 'The user denied the authorization request',
|
||||
state: state,
|
||||
})
|
||||
window.location.href = `${redirectUri}?${params}`
|
||||
window.open(`${redirectUri}?${params}`, "_self")
|
||||
}
|
||||
|
||||
function openTerms() {
|
||||
window.open(clientInfo.value?.terms_of_service_uri || 'https://example.com/terms', '_blank')
|
||||
window.open(clientInfo.value?.terms_of_service_uri || '#', "_blank")
|
||||
}
|
||||
|
||||
function openPrivacy() {
|
||||
window.open(clientInfo.value?.privacy_policy_uri || 'https://example.com/privacy', '_blank')
|
||||
window.open(clientInfo.value?.privacy_policy_uri || '#', "_blank")
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
|
@@ -24,7 +24,7 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
|
||||
}
|
||||
|
||||
var accountId = currentUser.Id!;
|
||||
var deviceId = currentSession.Challenge.DeviceId!;
|
||||
var deviceId = currentSession.Challenge?.DeviceId ?? Guid.NewGuid().ToString();
|
||||
|
||||
if (string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
@@ -32,7 +32,8 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
|
||||
return;
|
||||
}
|
||||
|
||||
using var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
|
||||
var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync(new WebSocketAcceptContext
|
||||
{ KeepAliveInterval = TimeSpan.FromSeconds(60) });
|
||||
var cts = new CancellationTokenSource();
|
||||
var connectionKey = (accountId, deviceId);
|
||||
|
||||
@@ -65,7 +66,12 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "WebSocket disconnected with user @{UserName}#{UserId} and device #{DeviceId} unexpectedly");
|
||||
logger.LogError(ex,
|
||||
"WebSocket disconnected with user @{UserName}#{UserId} and device #{DeviceId} unexpectedly",
|
||||
currentUser.Name,
|
||||
currentUser.Id,
|
||||
deviceId
|
||||
);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -99,7 +105,7 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
|
||||
break;
|
||||
|
||||
var packet = WebSocketPacket.FromBytes(buffer[..receiveResult.Count]);
|
||||
_ = ws.HandlePacket(currentUser, connectionKey.DeviceId, packet, webSocket);
|
||||
await ws.HandlePacket(currentUser, connectionKey.DeviceId, packet, webSocket);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
|
@@ -49,11 +49,18 @@ public class WebSocketService
|
||||
public void Disconnect((string AccountId, string DeviceId) key, string? reason = null)
|
||||
{
|
||||
if (!ActiveConnections.TryGetValue(key, out var data)) return;
|
||||
data.Socket.CloseAsync(
|
||||
WebSocketCloseStatus.NormalClosure,
|
||||
reason ?? "Server just decided to disconnect.",
|
||||
CancellationToken.None
|
||||
);
|
||||
try
|
||||
{
|
||||
data.Socket.CloseAsync(
|
||||
WebSocketCloseStatus.NormalClosure,
|
||||
reason ?? "Server just decided to disconnect.",
|
||||
CancellationToken.None
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error while closing WebSocket for {AccountId}:{DeviceId}", key.AccountId, key.DeviceId);
|
||||
}
|
||||
data.Cts.Cancel();
|
||||
ActiveConnections.TryRemove(key, out _);
|
||||
}
|
||||
|
@@ -56,8 +56,6 @@ public class PushService
|
||||
_ws = ws;
|
||||
_queueService = queueService;
|
||||
_logger = logger;
|
||||
|
||||
_logger.LogInformation("PushService initialized");
|
||||
}
|
||||
|
||||
public async Task UnsubscribeDevice(string deviceId)
|
||||
|
@@ -51,7 +51,6 @@ public class QueueBackgroundService(
|
||||
}
|
||||
else
|
||||
{
|
||||
await msg.ReplyAsync(cancellationToken: stoppingToken);
|
||||
logger.LogWarning($"Invalid message format for {msg.Subject}");
|
||||
}
|
||||
}
|
||||
@@ -92,8 +91,6 @@ public class QueueBackgroundService(
|
||||
logger.LogWarning("Unknown message type: {MessageType}", message.Type);
|
||||
break;
|
||||
}
|
||||
|
||||
await rawMsg.ReplyAsync(cancellationToken: cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
@@ -134,7 +134,7 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
public static IServiceCollection AddAppBusinessServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<WebSocketService>();
|
||||
services.AddSingleton<WebSocketService>();
|
||||
services.AddScoped<EmailService>();
|
||||
services.AddScoped<PushService>();
|
||||
|
||||
|
@@ -33,7 +33,10 @@ public class DysonTokenAuthHandler(
|
||||
AuthSession session;
|
||||
try
|
||||
{
|
||||
session = await ValidateToken(tokenInfo.Token);
|
||||
session = await ValidateToken(
|
||||
tokenInfo.Token,
|
||||
Request.HttpContext.Connection.RemoteIpAddress?.ToString()
|
||||
);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
@@ -58,7 +61,7 @@ public class DysonTokenAuthHandler(
|
||||
};
|
||||
|
||||
// Add scopes as claims
|
||||
session.Challenge.Scopes.ToList().ForEach(scope => claims.Add(new Claim("scope", scope)));
|
||||
session.Challenge?.Scopes.ToList().ForEach(scope => claims.Add(new Claim("scope", scope)));
|
||||
|
||||
// Add superuser claim if applicable
|
||||
if (session.Account.IsSuperuser)
|
||||
@@ -78,12 +81,15 @@ public class DysonTokenAuthHandler(
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AuthSession> ValidateToken(string token)
|
||||
private async Task<AuthSession> ValidateToken(string token, string? ipAddress)
|
||||
{
|
||||
var resp = await auth.AuthenticateAsync(new AuthenticateRequest { Token = token });
|
||||
var resp = await auth.AuthenticateAsync(new AuthenticateRequest
|
||||
{
|
||||
Token = token,
|
||||
IpAddress = ipAddress
|
||||
});
|
||||
if (!resp.Valid) throw new InvalidOperationException(resp.Message);
|
||||
if (resp.Session == null) throw new InvalidOperationException("Session not found.");
|
||||
return resp.Session;
|
||||
return resp.Session ?? throw new InvalidOperationException("Session not found.");
|
||||
}
|
||||
|
||||
private static byte[] Base64UrlDecode(string base64Url)
|
||||
|
@@ -20,8 +20,6 @@ public static class KestrelConfiguration
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
{
|
||||
options.Limits.MaxRequestBodySize = maxRequestBodySize;
|
||||
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2);
|
||||
options.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
var configuredUrl = Environment.GetEnvironmentVariable("ASPNETCORE_URLS");
|
||||
if (!string.IsNullOrEmpty(configuredUrl)) return;
|
||||
|
@@ -70,6 +70,7 @@ service AuthService {
|
||||
|
||||
message AuthenticateRequest {
|
||||
string token = 1;
|
||||
optional google.protobuf.StringValue ip_address = 2;
|
||||
}
|
||||
|
||||
message AuthenticateResponse {
|
||||
|
@@ -150,7 +150,7 @@ public class ChatRoomController(
|
||||
[Authorize]
|
||||
public async Task<ActionResult<ChatRoom>> GetDirectChatRoom(Guid accountId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser)
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var room = await db.ChatRooms
|
||||
@@ -968,23 +968,29 @@ public class ChatRoomController(
|
||||
|
||||
private async Task _SendInviteNotify(ChatMember member, Account sender)
|
||||
{
|
||||
var account = await accounts.GetAccountAsync(new GetAccountRequest { Id = member.AccountId.ToString() });
|
||||
CultureService.SetCultureInfo(account);
|
||||
|
||||
string title = localizer["ChatInviteTitle"];
|
||||
|
||||
string body = member.ChatRoom.Type == ChatRoomType.DirectMessage
|
||||
? localizer["ChatInviteDirectBody", sender.Nick]
|
||||
: localizer["ChatInviteBody", member.ChatRoom.Name ?? "Unnamed"];
|
||||
|
||||
CultureService.SetCultureInfo(member.Account!.Language);
|
||||
await pusher.SendPushNotificationToUserAsync(
|
||||
new SendPushNotificationToUserRequest
|
||||
{
|
||||
UserId = member.Account.Id.ToString(),
|
||||
UserId = account.Id,
|
||||
Notification = new PushNotification
|
||||
{
|
||||
Topic = "invites.chats",
|
||||
Title = title,
|
||||
Body = body,
|
||||
IsSavable = true
|
||||
IsSavable = true,
|
||||
Meta = GrpcTypeHelper.ConvertObjectToByteString(new
|
||||
{
|
||||
room_id = member.ChatRoomId
|
||||
})
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@@ -69,7 +69,8 @@ public partial class ChatService(
|
||||
dbMessage,
|
||||
dbMessage.Sender,
|
||||
dbMessage.ChatRoom,
|
||||
WebSocketPacketType.MessageUpdate
|
||||
WebSocketPacketType.MessageUpdate,
|
||||
notify: false
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -87,8 +88,7 @@ public partial class ChatService(
|
||||
/// <param name="message">The message to process</param>
|
||||
/// <param name="webReader">The web reader service</param>
|
||||
/// <returns>The message with link previews added to its meta data</returns>
|
||||
public async Task<Message> PreviewMessageLinkAsync(Message message,
|
||||
WebReader.WebReaderService? webReader = null)
|
||||
public async Task<Message> PreviewMessageLinkAsync(Message message, WebReaderService? webReader = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(message.Content))
|
||||
return message;
|
||||
@@ -110,8 +110,7 @@ public partial class ChatService(
|
||||
}
|
||||
|
||||
var embeds = (List<Dictionary<string, object>>)message.Meta["embeds"];
|
||||
webReader ??= scopeFactory.CreateScope().ServiceProvider
|
||||
.GetRequiredService<WebReader.WebReaderService>();
|
||||
webReader ??= scopeFactory.CreateScope().ServiceProvider.GetRequiredService<WebReaderService>();
|
||||
|
||||
// Process up to 3 links to avoid excessive processing
|
||||
var processedLinks = 0;
|
||||
@@ -195,7 +194,8 @@ public partial class ChatService(
|
||||
Message message,
|
||||
ChatMember sender,
|
||||
ChatRoom room,
|
||||
string type = WebSocketPacketType.MessageNew
|
||||
string type = WebSocketPacketType.MessageNew,
|
||||
bool notify = true
|
||||
)
|
||||
{
|
||||
message.Sender = sender;
|
||||
@@ -205,11 +205,29 @@ public partial class ChatService(
|
||||
var scopedNty = scope.ServiceProvider.GetRequiredService<PusherService.PusherServiceClient>();
|
||||
var scopedCrs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
|
||||
|
||||
var members = await scopedCrs.ListRoomMembers(room.Id);
|
||||
|
||||
var request = new PushWebSocketPacketToUsersRequest
|
||||
{
|
||||
Packet = new WebSocketPacket
|
||||
{
|
||||
Type = type,
|
||||
Data = GrpcTypeHelper.ConvertObjectToByteString(message),
|
||||
},
|
||||
};
|
||||
request.UserIds.AddRange(members.Select(a => a.Account).Where(a => a is not null)
|
||||
.Select(a => a!.Id.ToString()));
|
||||
await scopedNty.PushWebSocketPacketToUsersAsync(request);
|
||||
|
||||
if (!notify)
|
||||
{
|
||||
logger.LogInformation($"Delivered message to {request.UserIds.Count} accounts.");
|
||||
return;
|
||||
}
|
||||
|
||||
var roomSubject = room is { Type: ChatRoomType.DirectMessage, Name: null } ? "DM" :
|
||||
room.Realm is not null ? $"{room.Name}, {room.Realm.Name}" : room.Name;
|
||||
|
||||
var members = await scopedCrs.ListRoomMembers(room.Id);
|
||||
|
||||
if (sender.Account is null)
|
||||
sender = await scopedCrs.LoadMemberAccount(sender);
|
||||
if (sender.Account is null)
|
||||
@@ -273,18 +291,6 @@ public partial class ChatService(
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
var request = new PushWebSocketPacketToUsersRequest
|
||||
{
|
||||
Packet = new WebSocketPacket
|
||||
{
|
||||
Type = type,
|
||||
Data = GrpcTypeHelper.ConvertObjectToByteString(message),
|
||||
},
|
||||
};
|
||||
request.UserIds.AddRange(members.Select(a => a.Account).Where(a => a is not null)
|
||||
.Select(a => a!.Id.ToString()));
|
||||
await scopedNty.PushWebSocketPacketToUsersAsync(request);
|
||||
|
||||
List<Account> accountsToNotify = [];
|
||||
foreach (
|
||||
var member in members
|
||||
|
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div v-if="post" class="container max-w-5xl mx-auto mt-4">
|
||||
<n-grid cols="1 l:5" responsive="screen" :x-gap="16">
|
||||
<n-grid cols="1 l:5" responsive="screen" :x-gap="16" :y-gap="16">
|
||||
<n-gi span="3">
|
||||
<post-item :item="post" />
|
||||
</n-gi>
|
||||
|
@@ -51,17 +51,15 @@ public class PostPageData(
|
||||
.Include(e => e.Categories)
|
||||
.FilterWithVisibility(currentUser, userFriends, userPublishers)
|
||||
.FirstOrDefaultAsync();
|
||||
if (post == null) return new Dictionary<string, object?>();
|
||||
post = await ps.LoadPostInfo(post, currentUser);
|
||||
|
||||
// Track view - use the account ID as viewer ID if user is logged in
|
||||
await ps.IncreaseViewCount(post.Id, currentUser?.Id);
|
||||
|
||||
var og = OpenGraph.MakeGraph(
|
||||
title: post.Title ?? $"Post from {post.Publisher.Name}",
|
||||
type: "article",
|
||||
image: $"{_siteUrl}/cgi/drive/files/{post.Publisher.Background?.Id}?original=true",
|
||||
url: $"{_siteUrl}/@{slug}",
|
||||
description: post.Description ?? post.Content?[..80] ?? "Posted with some media",
|
||||
description: post.Description ?? (post.Content?.Length > 80 ? post.Content?[..80] : post.Content) ?? "Posted with some media",
|
||||
siteName: "Solar Network"
|
||||
);
|
||||
|
||||
|
@@ -31,7 +31,7 @@ public class StickerController(
|
||||
return NotFound("Sticker pack not found");
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
if (!await ps.IsMemberWithRole(accountId, pack.PublisherId, requiredRole))
|
||||
if (!await ps.IsMemberWithRole(pack.PublisherId, accountId, requiredRole))
|
||||
return StatusCode(403, "You are not a member of this publisher");
|
||||
|
||||
return Ok();
|
||||
|
@@ -146,6 +146,7 @@
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ATusDiskStore_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003F1c_003F21999acd_003FTusDiskStore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AUri_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F5d2c480da9be415dab9be535bb6d08713cc00_003Fd0_003Fffc36a51_003FUri_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AValidationContext_00601_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003F6b_003F741ceebe_003FValidationContext_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AWebSocketAcceptContext_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F16e10e91b7834a87b2f3f4f30bfada3ee000_003Fd0_003F44ef97dc_003FWebSocketAcceptContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AWebSocketCloseStatus_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F82dcad099d814e3facee3a7c5e19928a3ae00_003F67_003F9e63fab4_003FWebSocketCloseStatus_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/Environment/AssemblyExplorer/XmlDocument/@EntryValue"><AssemblyExplorer>
|
||||
<Assembly Path="/opt/homebrew/Cellar/dotnet/9.0.6/libexec/packs/Microsoft.AspNetCore.App.Ref/9.0.6/ref/net9.0/Microsoft.AspNetCore.RateLimiting.dll" />
|
||||
|
Reference in New Issue
Block a user