Compare commits

...

16 Commits

Author SHA1 Message Date
LittleSheep
fb6721cb1b 💄 Optimize punishment reason display 2025-08-26 20:32:07 +08:00
LittleSheep
9fcb169c94 🐛 Fix chat room invites 2025-08-26 19:08:23 +08:00
LittleSheep
572874431d 🐛 Fix sticker perm check 2025-08-26 14:48:30 +08:00
LittleSheep
f595ac8001 🐛 Fix uploading file didn't uploaded 2025-08-26 13:02:51 +08:00
LittleSheep
18674e0e1d Remove /cgi directly handled by gateway 2025-08-26 02:59:51 +08:00
LittleSheep
da4c4d3a84 🐛 Fix bugs 2025-08-26 02:48:16 +08:00
LittleSheep
aec01b117d 🐛 Fix chat service duplicate notifying 2025-08-26 00:15:39 +08:00
LittleSheep
d299c32e35 ♻️ Clean up OIDC provider 2025-08-25 23:53:04 +08:00
LittleSheep
344007af66 🔊 Logging more ip address 2025-08-25 23:42:41 +08:00
LittleSheep
d4de5aeac2 🐛 Fix api key exists cause regular login 500 2025-08-25 23:30:41 +08:00
LittleSheep
8ce5ba50f4 🐛 Fix api key cause 401 in other serivces 2025-08-25 23:20:27 +08:00
LittleSheep
5a44952b27 🐛 Fix oidc token aud 2025-08-25 23:17:40 +08:00
LittleSheep
c30946daf6 🐛 Still bug fixes in auth service 2025-08-25 23:01:17 +08:00
LittleSheep
0221d7b294 🐛 Fix compress GIF wrongly 2025-08-25 22:42:14 +08:00
LittleSheep
c44b0b64c3 🐛 Fix api key auth issue 2025-08-25 22:39:35 +08:00
LittleSheep
442ee3bcfd 🐛 Fixes in auth service 2025-08-25 22:24:18 +08:00
18 changed files with 224 additions and 150 deletions

View File

@@ -337,8 +337,14 @@ public class FileService(
if (!pool.PolicyConfig.NoOptimization) if (!pool.PolicyConfig.NoOptimization)
switch (contentType.Split('/')[0]) switch (contentType.Split('/')[0])
{ {
case "image" when !AnimatedImageTypes.Contains(contentType) && case "image":
!AnimatedImageExtensions.Contains(fileExtension): 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"; newMimeType = "image/webp";
using (var vipsImage = Image.NewFromFile(originalFilePath)) using (var vipsImage = Image.NewFromFile(originalFilePath))
{ {
@@ -672,8 +678,8 @@ public class FileService(
foreach (var file in fileGroup) foreach (var file in fileGroup)
{ {
objectsToDelete.Add(file.StorageId ?? file.Id); objectsToDelete.Add(file.StorageId ?? file.Id);
if(file.HasCompression) objectsToDelete.Add(file.StorageId ?? file.Id + ".compressed"); if (file.HasCompression) objectsToDelete.Add(file.StorageId ?? file.Id + ".compressed");
if(file.HasThumbnail) objectsToDelete.Add(file.StorageId ?? file.Id + ".thumbnail"); if (file.HasThumbnail) objectsToDelete.Add(file.StorageId ?? file.Id + ".thumbnail");
} }
await client.RemoveObjectsAsync( await client.RemoveObjectsAsync(

View File

@@ -76,26 +76,6 @@ public class RegistryProxyConfigProvider : IProxyConfigProvider, IDisposable
var gatewayServiceName = _configuration["Service:Name"]; 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 // Add direct routes
foreach (var directRoute in directRoutes) foreach (var directRoute in directRoutes)
{ {

View File

@@ -49,7 +49,10 @@ public class DysonTokenAuthHandler(
try 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) if (!valid || session is null)
return AuthenticateResult.Fail(message ?? "Authentication failed."); return AuthenticateResult.Fail(message ?? "Authentication failed.");
@@ -67,7 +70,7 @@ public class DysonTokenAuthHandler(
}; };
// Add scopes as claims // 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 // Add superuser claim if applicable
if (session.Account.IsSuperuser) if (session.Account.IsSuperuser)

View File

@@ -51,7 +51,11 @@ public class AuthController(
.Where(e => e.Type == PunishmentType.BlockLogin || e.Type == PunishmentType.DisableAccount) .Where(e => e.Type == PunishmentType.BlockLogin || e.Type == PunishmentType.DisableAccount)
.Where(e => e.ExpiredAt == null || now < e.ExpiredAt) .Where(e => e.ExpiredAt == null || now < e.ExpiredAt)
.FirstOrDefaultAsync(); .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 ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
var userAgent = HttpContext.Request.Headers.UserAgent.ToString(); var userAgent = HttpContext.Request.Headers.UserAgent.ToString();

View File

@@ -52,7 +52,7 @@ public class AuthService(
riskScore += 1; riskScore += 1;
else else
{ {
if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge.IpAddress) && if (!string.IsNullOrEmpty(lastActiveInfo?.Challenge?.IpAddress) &&
!lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase)) !lastActiveInfo.Challenge.IpAddress.Equals(ipAddress, StringComparison.OrdinalIgnoreCase))
riskScore += 1; riskScore += 1;
} }

View File

@@ -1,9 +1,5 @@
using DysonNetwork.Pass.Wallet;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using Grpc.Core; using Grpc.Core;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Auth; namespace DysonNetwork.Pass.Auth;
@@ -18,7 +14,7 @@ public class AuthServiceGrpc(
ServerCallContext context 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) if (!valid || session is null)
return new AuthenticateResponse { Valid = false, Message = message ?? "Authentication failed." }; return new AuthenticateResponse { Valid = false, Message = message ?? "Authentication failed." };

View File

@@ -126,7 +126,6 @@ public class OidcProviderController(
[FromForm(Name = "redirect_uri")] string? redirectUri = null, [FromForm(Name = "redirect_uri")] string? redirectUri = null,
[FromForm] string? scope = null, [FromForm] string? scope = null,
[FromForm] string? state = null, [FromForm] string? state = null,
[FromForm(Name = "response_type")] string? responseType = null,
[FromForm] string? nonce = null, [FromForm] string? nonce = null,
[FromForm(Name = "code_challenge")] string? codeChallenge = null, [FromForm(Name = "code_challenge")] string? codeChallenge = null,
[FromForm(Name = "code_challenge_method")] [FromForm(Name = "code_challenge_method")]
@@ -191,7 +190,8 @@ public class OidcProviderController(
scope?.Split(' ') ?? [], scope?.Split(' ') ?? [],
codeChallenge, codeChallenge,
codeChallengeMethod, codeChallengeMethod,
nonce); nonce
);
// Build the redirect URI with the authorization code // Build the redirect URI with the authorization code
var redirectBuilder = new UriBuilder(redirectUri); var redirectBuilder = new UriBuilder(redirectUri);
@@ -307,7 +307,7 @@ public class OidcProviderController(
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized(); HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
// Get requested scopes from the token // Get requested scopes from the token
var scopes = currentSession.Challenge.Scopes; var scopes = currentSession.Challenge?.Scopes ?? [];
var userInfo = new Dictionary<string, object> var userInfo = new Dictionary<string, object>
{ {

View File

@@ -20,7 +20,6 @@ public class TokenResponse
[JsonPropertyName("scope")] [JsonPropertyName("scope")]
public string? Scope { get; set; } public string? Scope { get; set; }
[JsonPropertyName("id_token")] [JsonPropertyName("id_token")]
public string? IdToken { get; set; } public string? IdToken { get; set; }
} }

View File

@@ -11,6 +11,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using NodaTime; using NodaTime;
using AccountContactType = DysonNetwork.Pass.Account.AccountContactType;
namespace DysonNetwork.Pass.Auth.OidcProvider.Services; namespace DysonNetwork.Pass.Auth.OidcProvider.Services;
@@ -37,12 +38,21 @@ public class OidcProviderService(
return resp.App ?? null; 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(); var now = SystemClock.Instance.GetCurrentInstant();
return await db.AuthSessions var queryable = db.AuthSessions
.Include(s => s.Challenge) .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 && .Where(s => s.AccountId == accountId &&
s.AppId == clientId && s.AppId == clientId &&
(s.ExpiredAt == null || s.ExpiredAt > now) && (s.ExpiredAt == null || s.ExpiredAt > now) &&
@@ -67,12 +77,12 @@ public class OidcProviderService(
{ {
if (string.IsNullOrEmpty(redirectUri)) if (string.IsNullOrEmpty(redirectUri))
return false; return false;
var client = await FindClientByIdAsync(clientId); var client = await FindClientByIdAsync(clientId);
if (client?.Status != CustomAppStatus.Production) if (client?.Status != CustomAppStatus.Production)
return true; return true;
if (client?.OauthConfig?.RedirectUris == null) if (client?.OauthConfig?.RedirectUris == null)
return false; return false;
@@ -114,7 +124,7 @@ public class OidcProviderService(
var allowedPath = allowedUriObj.AbsolutePath.TrimEnd('/'); var allowedPath = allowedUriObj.AbsolutePath.TrimEnd('/');
var redirectPath = redirectUriObj.AbsolutePath.TrimEnd('/'); var redirectPath = redirectUriObj.AbsolutePath.TrimEnd('/');
if (string.IsNullOrEmpty(allowedPath) || if (string.IsNullOrEmpty(allowedPath) ||
redirectPath.StartsWith(allowedPath, StringComparison.OrdinalIgnoreCase)) redirectPath.StartsWith(allowedPath, StringComparison.OrdinalIgnoreCase))
{ {
return true; return true;
@@ -133,6 +143,79 @@ public class OidcProviderService(
return false; 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( public async Task<TokenResponse> GenerateTokenResponseAsync(
Guid clientId, Guid clientId,
string? authorizationCode = null, string? authorizationCode = null,
@@ -148,24 +231,43 @@ public class OidcProviderService(
AuthSession session; AuthSession session;
var clock = SystemClock.Instance; var clock = SystemClock.Instance;
var now = clock.GetCurrentInstant(); var now = clock.GetCurrentInstant();
string? nonce = null;
List<string>? scopes = null; List<string>? scopes = null;
if (authorizationCode != null) if (authorizationCode != null)
{ {
// Authorization code flow // Authorization code flow
var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier); var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
if (authCode is null) throw new InvalidOperationException("Invalid authorization code"); if (authCode == null)
var account = await db.Accounts.Where(a => a.Id == authCode.AccountId).FirstOrDefaultAsync(); throw new InvalidOperationException("Invalid authorization code");
if (account is null) throw new InvalidOperationException("Account was not found");
// 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; scopes = authCode.Scopes;
nonce = authCode.Nonce;
} }
else if (sessionId.HasValue) else if (sessionId.HasValue)
{ {
// Refresh token flow // Refresh token flow
session = await FindSessionByIdAsync(sessionId.Value) ?? session = await FindSessionByIdAsync(sessionId.Value) ??
throw new InvalidOperationException("Invalid session"); throw new InvalidOperationException("Session not found");
// Verify the session is still valid // Verify the session is still valid
if (session.ExpiredAt < now) if (session.ExpiredAt < now)
@@ -179,13 +281,15 @@ public class OidcProviderService(
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds; var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn)); var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
// Generate an access token // Generate tokens
var accessToken = GenerateJwtToken(client, session, expiresAt, scopes); var accessToken = GenerateJwtToken(client, session, expiresAt, scopes);
var idToken = GenerateIdToken(client, session, nonce, scopes);
var refreshToken = GenerateRefreshToken(session); var refreshToken = GenerateRefreshToken(session);
return new TokenResponse return new TokenResponse
{ {
AccessToken = accessToken, AccessToken = accessToken,
IdToken = idToken,
ExpiresIn = expiresIn, ExpiresIn = expiresIn,
TokenType = "Bearer", TokenType = "Bearer",
RefreshToken = refreshToken, RefreshToken = refreshToken,
@@ -211,11 +315,10 @@ public class OidcProviderService(
new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()), new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(), new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
ClaimValueTypes.Integer64), ClaimValueTypes.Integer64),
new Claim("client_id", client.Id)
]), ]),
Expires = expiresAt.ToDateTimeUtc(), Expires = expiresAt.ToDateTimeUtc(),
Issuer = _options.IssuerUri, Issuer = _options.IssuerUri,
Audience = client.Id Audience = client.Slug
}; };
// Try to use RSA signing if keys are available, fall back to HMAC // 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()); 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( public async Task<string> GenerateAuthorizationCodeAsync(
Guid clientId, Guid clientId,
Guid userId, Guid userId,
@@ -355,7 +413,7 @@ public class OidcProviderService(
}; };
// Store the code with its metadata in the cache // 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); await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId); logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId);
@@ -369,7 +427,7 @@ public class OidcProviderService(
string? codeVerifier = null string? codeVerifier = null
) )
{ {
var cacheKey = $"auth:code:{code}"; var cacheKey = $"auth:oidc-code:{code}";
var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey); var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey);
if (!found || authCode == null) if (!found || authCode == null)

View File

@@ -1,3 +1,4 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using DysonNetwork.Pass.Wallet; using DysonNetwork.Pass.Wallet;
@@ -22,8 +23,9 @@ public class TokenAuthService(
/// then cache and return. /// then cache and return.
/// </summary> /// </summary>
/// <param name="token">Incoming token string</param> /// <param name="token">Incoming token string</param>
/// <param name="ipAddress">Client IP address, for logging purposes</param>
/// <returns>(Valid, Session, Message)</returns> /// <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 try
{ {
@@ -32,6 +34,11 @@ public class TokenAuthService(
logger.LogWarning("AuthenticateTokenAsync: no token provided"); logger.LogWarning("AuthenticateTokenAsync: no token provided");
return (false, null, "No token provided."); return (false, null, "No token provided.");
} }
if (!string.IsNullOrEmpty(ipAddress))
{
logger.LogDebug("AuthenticateTokenAsync: client IP: {IpAddress}", ipAddress);
}
// token fingerprint for correlation // token fingerprint for correlation
var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
@@ -70,7 +77,7 @@ public class TokenAuthService(
"AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})", "AuthenticateTokenAsync: success via cache (sessionId={SessionId}, accountId={AccountId}, scopes={ScopeCount}, expiresAt={ExpiresAt})",
sessionId, sessionId,
session.AccountId, session.AccountId,
session.Challenge.Scopes.Count, session.Challenge?.Scopes.Count,
session.ExpiredAt session.ExpiredAt
); );
return (true, session, null); 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})", "AuthenticateTokenAsync: DB session loaded (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId}, appId={AppId}, scopes={ScopeCount}, ip={Ip}, uaLen={UaLen})",
sessionId, sessionId,
session.AccountId, session.AccountId,
session.Challenge.ClientId, session.Challenge?.ClientId,
session.AppId, session.AppId,
session.Challenge.Scopes.Count, session.Challenge?.Scopes.Count,
session.Challenge.IpAddress, session.Challenge?.IpAddress,
(session.Challenge.UserAgent ?? string.Empty).Length (session.Challenge?.UserAgent ?? string.Empty).Length
); );
logger.LogDebug("AuthenticateTokenAsync: enriching account with subscription (accountId={AccountId})", session.AccountId); 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})", "AuthenticateTokenAsync: success via DB (sessionId={SessionId}, accountId={AccountId}, clientId={ClientId})",
sessionId, sessionId,
session.AccountId, session.AccountId,
session.Challenge.ClientId session.Challenge?.ClientId
); );
return (true, session, null); return (true, session, null);
} }

View File

@@ -24,7 +24,7 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
} }
var accountId = currentUser.Id!; var accountId = currentUser.Id!;
var deviceId = currentSession.Challenge.DeviceId!; var deviceId = currentSession.Challenge?.DeviceId ?? Guid.NewGuid().ToString();
if (string.IsNullOrEmpty(deviceId)) if (string.IsNullOrEmpty(deviceId))
{ {
@@ -67,7 +67,11 @@ public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext>
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, logger.LogError(ex,
"WebSocket disconnected with user @{UserName}#{UserId} and device #{DeviceId} unexpectedly"); "WebSocket disconnected with user @{UserName}#{UserId} and device #{DeviceId} unexpectedly",
currentUser.Name,
currentUser.Id,
deviceId
);
} }
finally finally
{ {

View File

@@ -33,7 +33,10 @@ public class DysonTokenAuthHandler(
AuthSession session; AuthSession session;
try try
{ {
session = await ValidateToken(tokenInfo.Token); session = await ValidateToken(
tokenInfo.Token,
Request.HttpContext.Connection.RemoteIpAddress?.ToString()
);
} }
catch (InvalidOperationException ex) catch (InvalidOperationException ex)
{ {
@@ -58,7 +61,7 @@ public class DysonTokenAuthHandler(
}; };
// Add scopes as claims // 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 // Add superuser claim if applicable
if (session.Account.IsSuperuser) 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.Valid) throw new InvalidOperationException(resp.Message);
if (resp.Session == null) throw new InvalidOperationException("Session not found."); return resp.Session ?? throw new InvalidOperationException("Session not found.");
return resp.Session;
} }
private static byte[] Base64UrlDecode(string base64Url) private static byte[] Base64UrlDecode(string base64Url)

View File

@@ -70,6 +70,7 @@ service AuthService {
message AuthenticateRequest { message AuthenticateRequest {
string token = 1; string token = 1;
optional google.protobuf.StringValue ip_address = 2;
} }
message AuthenticateResponse { message AuthenticateResponse {

View File

@@ -150,7 +150,7 @@ public class ChatRoomController(
[Authorize] [Authorize]
public async Task<ActionResult<ChatRoom>> GetDirectChatRoom(Guid accountId) 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(); return Unauthorized();
var room = await db.ChatRooms var room = await db.ChatRooms
@@ -968,25 +968,31 @@ public class ChatRoomController(
private async Task _SendInviteNotify(ChatMember member, Account sender) 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 title = localizer["ChatInviteTitle"];
string body = member.ChatRoom.Type == ChatRoomType.DirectMessage string body = member.ChatRoom.Type == ChatRoomType.DirectMessage
? localizer["ChatInviteDirectBody", sender.Nick] ? localizer["ChatInviteDirectBody", sender.Nick]
: localizer["ChatInviteBody", member.ChatRoom.Name ?? "Unnamed"]; : localizer["ChatInviteBody", member.ChatRoom.Name ?? "Unnamed"];
CultureService.SetCultureInfo(member.Account!.Language);
await pusher.SendPushNotificationToUserAsync( await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest new SendPushNotificationToUserRequest
{ {
UserId = member.Account.Id.ToString(), UserId = account.Id,
Notification = new PushNotification Notification = new PushNotification
{ {
Topic = "invites.chats", Topic = "invites.chats",
Title = title, Title = title,
Body = body, Body = body,
IsSavable = true IsSavable = true,
Meta = GrpcTypeHelper.ConvertObjectToByteString(new
{
room_id = member.ChatRoomId
})
} }
} }
); );
} }
} }

View File

@@ -69,7 +69,8 @@ public partial class ChatService(
dbMessage, dbMessage,
dbMessage.Sender, dbMessage.Sender,
dbMessage.ChatRoom, 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="message">The message to process</param>
/// <param name="webReader">The web reader service</param> /// <param name="webReader">The web reader service</param>
/// <returns>The message with link previews added to its meta data</returns> /// <returns>The message with link previews added to its meta data</returns>
public async Task<Message> PreviewMessageLinkAsync(Message message, public async Task<Message> PreviewMessageLinkAsync(Message message, WebReaderService? webReader = null)
WebReader.WebReaderService? webReader = null)
{ {
if (string.IsNullOrEmpty(message.Content)) if (string.IsNullOrEmpty(message.Content))
return message; return message;
@@ -110,8 +110,7 @@ public partial class ChatService(
} }
var embeds = (List<Dictionary<string, object>>)message.Meta["embeds"]; var embeds = (List<Dictionary<string, object>>)message.Meta["embeds"];
webReader ??= scopeFactory.CreateScope().ServiceProvider webReader ??= scopeFactory.CreateScope().ServiceProvider.GetRequiredService<WebReaderService>();
.GetRequiredService<WebReader.WebReaderService>();
// Process up to 3 links to avoid excessive processing // Process up to 3 links to avoid excessive processing
var processedLinks = 0; var processedLinks = 0;
@@ -195,7 +194,8 @@ public partial class ChatService(
Message message, Message message,
ChatMember sender, ChatMember sender,
ChatRoom room, ChatRoom room,
string type = WebSocketPacketType.MessageNew string type = WebSocketPacketType.MessageNew,
bool notify = true
) )
{ {
message.Sender = sender; message.Sender = sender;
@@ -205,11 +205,29 @@ public partial class ChatService(
var scopedNty = scope.ServiceProvider.GetRequiredService<PusherService.PusherServiceClient>(); var scopedNty = scope.ServiceProvider.GetRequiredService<PusherService.PusherServiceClient>();
var scopedCrs = scope.ServiceProvider.GetRequiredService<ChatRoomService>(); 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" : var roomSubject = room is { Type: ChatRoomType.DirectMessage, Name: null } ? "DM" :
room.Realm is not null ? $"{room.Name}, {room.Realm.Name}" : room.Name; room.Realm is not null ? $"{room.Name}, {room.Realm.Name}" : room.Name;
var members = await scopedCrs.ListRoomMembers(room.Id);
if (sender.Account is null) if (sender.Account is null)
sender = await scopedCrs.LoadMemberAccount(sender); sender = await scopedCrs.LoadMemberAccount(sender);
if (sender.Account is null) if (sender.Account is null)
@@ -273,18 +291,6 @@ public partial class ChatService(
var now = SystemClock.Instance.GetCurrentInstant(); 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 = []; List<Account> accountsToNotify = [];
foreach ( foreach (
var member in members var member in members
@@ -534,23 +540,23 @@ public partial class ChatService(
// If no messages need senders, return with the latest timestamp from changes // If no messages need senders, return with the latest timestamp from changes
if (messagesNeedingSenders.Count <= 0) if (messagesNeedingSenders.Count <= 0)
{ {
var latestTimestamp = changes.Count > 0 var latestTimestamp = changes.Count > 0
? changes.Max(c => c.Timestamp) ? changes.Max(c => c.Timestamp)
: SystemClock.Instance.GetCurrentInstant(); : SystemClock.Instance.GetCurrentInstant();
return new SyncResponse return new SyncResponse
{ {
Changes = changes, Changes = changes,
CurrentTimestamp = latestTimestamp CurrentTimestamp = latestTimestamp
}; };
} }
// Load member accounts for messages that need them // Load member accounts for messages that need them
var changesMembers = messagesNeedingSenders var changesMembers = messagesNeedingSenders
.Select(m => m.Sender) .Select(m => m.Sender)
.DistinctBy(x => x.Id) .DistinctBy(x => x.Id)
.ToList(); .ToList();
changesMembers = await crs.LoadMemberAccounts(changesMembers); changesMembers = await crs.LoadMemberAccounts(changesMembers);
// Update sender information for messages that have it // Update sender information for messages that have it
@@ -562,10 +568,10 @@ public partial class ChatService(
} }
// Use the latest timestamp from changes, or current time if no changes // Use the latest timestamp from changes, or current time if no changes
var latestChangeTimestamp = changes.Count > 0 var latestChangeTimestamp = changes.Count > 0
? changes.Max(c => c.Timestamp) ? changes.Max(c => c.Timestamp)
: SystemClock.Instance.GetCurrentInstant(); : SystemClock.Instance.GetCurrentInstant();
return new SyncResponse return new SyncResponse
{ {
Changes = changes, Changes = changes,

View File

@@ -1,6 +1,6 @@
<template> <template>
<div v-if="post" class="container max-w-5xl mx-auto mt-4"> <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"> <n-gi span="3">
<post-item :item="post" /> <post-item :item="post" />
</n-gi> </n-gi>

View File

@@ -51,17 +51,15 @@ public class PostPageData(
.Include(e => e.Categories) .Include(e => e.Categories)
.FilterWithVisibility(currentUser, userFriends, userPublishers) .FilterWithVisibility(currentUser, userFriends, userPublishers)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (post == null) return new Dictionary<string, object?>();
post = await ps.LoadPostInfo(post, currentUser); 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( var og = OpenGraph.MakeGraph(
title: post.Title ?? $"Post from {post.Publisher.Name}", title: post.Title ?? $"Post from {post.Publisher.Name}",
type: "article", type: "article",
image: $"{_siteUrl}/cgi/drive/files/{post.Publisher.Background?.Id}?original=true", image: $"{_siteUrl}/cgi/drive/files/{post.Publisher.Background?.Id}?original=true",
url: $"{_siteUrl}/@{slug}", 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" siteName: "Solar Network"
); );

View File

@@ -31,7 +31,7 @@ public class StickerController(
return NotFound("Sticker pack not found"); return NotFound("Sticker pack not found");
var accountId = Guid.Parse(currentUser.Id); 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 StatusCode(403, "You are not a member of this publisher");
return Ok(); return Ok();