Files
flatrender/services/identity/FlatRender.IdentitySvc/Application/Services/AuthService.cs
T
soroush.asadi 675b60d858
Build backend images / build content-svc (push) Failing after 1m2s
Build backend images / build file-svc (push) Failing after 3m11s
Build backend images / build gateway (push) Failing after 5m39s
Build backend images / build identity-svc (push) Failing after 38s
Build backend images / build notification-svc (push) Failing after 2m0s
Build backend images / build render-svc (push) Failing after 58s
Build backend images / build studio-svc (push) Failing after 58s
feat(auth+admin): Sign in with Google (OAuth) + Integrations config panel
Backend (identity-svc):
- oauth_config table (mig 22) + OAuthConfig entity
- OAuthService: admin config CRUD + Google authorization-code flow (build consent
  URL, exchange code, fetch userinfo, find/create RegisterMode.Google user, issue
  session via AuthService.IssueOAuthSessionAsync)
- AuthController: GET /v1/auth/google/{start,callback} (public); tokens handed to
  frontend via URL fragment
- AdminController: GET/PUT /v1/admin/oauth/{provider} (admin, secret masked)

Frontend:
- "ورود با گوگل" button on /auth → identity start endpoint
- /auth/callback reads fragment tokens → /api/auth/oauth-session sets httpOnly cookies
- /admin/integrations: Google client_id/secret/redirect_uri + enable, with setup guide
- nav + fa/en labels

Client ID/Secret are configured entirely in the admin panel — no redeploy needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 00:08:21 +03:30

506 lines
20 KiB
C#

using FlatRender.IdentitySvc.Application.Services.Interfaces;
using FlatRender.IdentitySvc.Domain.Entities;
using FlatRender.IdentitySvc.Domain.Enums;
using FlatRender.IdentitySvc.Infrastructure.Data;
using FlatRender.IdentitySvc.Models.Requests;
using FlatRender.IdentitySvc.Models.Responses;
using Microsoft.EntityFrameworkCore;
using OtpNet; // Otp.NET package
namespace FlatRender.IdentitySvc.Application.Services;
public class AuthService(
IdentityDbContext db,
ITokenService tokenService,
IConfiguration config) : IAuthService
{
private readonly int _refreshTokenDays = int.Parse(config["Jwt:RefreshTokenDays"] ?? "30");
public async Task<RegisterResponse> RegisterAsync(RegisterRequest request, string? ipAddress)
{
var tenant = await db.Tenants.FirstOrDefaultAsync(t => t.Slug == request.TenantSlug && t.DeletedAt == null)
?? throw new InvalidOperationException("Tenant not found");
if (string.IsNullOrEmpty(request.Email) && string.IsNullOrEmpty(request.PhoneNumber))
throw new ArgumentException("Email or phone number is required");
if (!string.IsNullOrEmpty(request.Email))
{
var exists = await db.Users.AnyAsync(u => u.TenantId == tenant.Id && u.Email == request.Email && u.DeletedAt == null);
if (exists) throw new InvalidOperationException("Email already registered");
}
var user = new User
{
TenantId = tenant.Id,
Email = request.Email?.ToLowerInvariant(),
PhoneNumber = request.PhoneNumber,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
PasswordSetAt = DateTime.UtcNow,
FullName = request.FullName,
RegisterMode = string.IsNullOrEmpty(request.PhoneNumber) ? RegisterMode.Email : RegisterMode.Mobile,
RegisterDate = DateTime.UtcNow,
};
db.Users.Add(user);
await db.SaveChangesAsync();
// Create email verification token
bool verificationRequired = false;
if (!string.IsNullOrEmpty(user.Email))
{
verificationRequired = true;
await CreateConfirmationTokenAsync(user.Id, tenant.Id, TokenPurpose.EmailVerification, user.Email, ipAddress);
}
return new RegisterResponse(user.Id, verificationRequired);
}
public async Task<AuthTokensResponse> LoginAsync(LoginRequest request, string? ipAddress)
{
var tenant = await db.Tenants.FirstOrDefaultAsync(t => t.Slug == request.TenantSlug && t.DeletedAt == null)
?? throw new UnauthorizedAccessException("Tenant not found");
User? user = null;
if (!string.IsNullOrEmpty(request.Email))
user = await db.Users.FirstOrDefaultAsync(u => u.TenantId == tenant.Id && u.Email == request.Email && u.DeletedAt == null);
else if (!string.IsNullOrEmpty(request.PhoneNumber))
user = await db.Users.FirstOrDefaultAsync(u => u.TenantId == tenant.Id && u.PhoneNumber == request.PhoneNumber && u.DeletedAt == null);
if (user == null || string.IsNullOrEmpty(user.PasswordHash))
throw new UnauthorizedAccessException("Invalid credentials");
if (!BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
throw new UnauthorizedAccessException("Invalid credentials");
if (user.BanAccount && (user.UnblockDate == null || user.UnblockDate > DateTime.UtcNow))
throw new UnauthorizedAccessException("Account is banned");
// Check MFA
var mfaFactor = await db.MfaFactors.FirstOrDefaultAsync(m => m.UserId == user.Id && m.IsVerified && m.IsPrimary);
if (mfaFactor != null)
{
// Return mfa_token — caller must complete challenge
throw new MfaRequiredException(GenerateMfaToken(user.Id, tenant.Id), "MFA required");
}
user.LastLoginAt = DateTime.UtcNow;
user.LastLoginIp = ipAddress;
user.LastActiveDate = DateTime.UtcNow;
var tokens = await CreateSessionAsync(user, tenant, request.DeviceId, request.DeviceName, ipAddress);
return tokens;
}
public async Task<AuthTokensResponse> RefreshAsync(string refreshToken)
{
var tokenHash = tokenService.HashToken(refreshToken);
var session = await db.UserSessions
.Include(s => s.User)
.FirstOrDefaultAsync(s => s.RefreshTokenHash == tokenHash && s.RevokedAt == null && s.ExpiresAt > DateTime.UtcNow)
?? throw new UnauthorizedAccessException("Invalid or expired refresh token");
var tenant = await db.Tenants.FindAsync(session.TenantId)
?? throw new UnauthorizedAccessException("Tenant not found");
// Rotate refresh token
session.RevokedAt = DateTime.UtcNow;
var tokens = await CreateSessionAsync(session.User, tenant, session.DeviceId, session.DeviceName, null, session.Id);
await db.SaveChangesAsync();
return tokens;
}
public async Task LogoutAsync(Guid sessionId, Guid userId)
{
var session = await db.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId);
if (session != null)
{
session.RevokedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
}
public async Task<List<SessionResponse>> GetSessionsAsync(Guid userId)
{
var sessions = await db.UserSessions
.Where(s => s.UserId == userId && s.RevokedAt == null && s.ExpiresAt > DateTime.UtcNow)
.OrderByDescending(s => s.LastUsedAt ?? s.IssuedAt)
.ToListAsync();
return sessions.Select(s => new SessionResponse(
s.Id, s.DeviceName, s.UserAgent, s.IpAddress,
s.IssuedAt, s.LastUsedAt, false
)).ToList();
}
public async Task RevokeSessionAsync(Guid sessionId, Guid userId)
{
var session = await db.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId)
?? throw new KeyNotFoundException("Session not found");
session.RevokedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task<bool> VerifyEmailAsync(string tokenHash, string code)
{
var confirmation = await db.ConfirmationTokens
.FirstOrDefaultAsync(t =>
t.TokenHash == tokenHash &&
t.Purpose == TokenPurpose.EmailVerification &&
!t.IsConsumed &&
t.ExpiresAt > DateTime.UtcNow)
?? throw new InvalidOperationException("Invalid or expired token");
if (confirmation.TryCount >= confirmation.MaxTries)
throw new InvalidOperationException("Too many attempts");
confirmation.TryCount++;
if (confirmation.Code != code)
{
await db.SaveChangesAsync();
return false;
}
confirmation.IsConsumed = true;
confirmation.ConsumedAt = DateTime.UtcNow;
if (confirmation.UserId.HasValue)
{
var user = await db.Users.FindAsync(confirmation.UserId.Value);
if (user != null)
{
user.EmailVerified = true;
user.EmailVerifiedAt = DateTime.UtcNow;
}
}
await db.SaveChangesAsync();
return true;
}
public async Task<bool> VerifyPhoneAsync(string tokenHash, string code)
{
var confirmation = await db.ConfirmationTokens
.FirstOrDefaultAsync(t =>
t.TokenHash == tokenHash &&
t.Purpose == TokenPurpose.PhoneVerification &&
!t.IsConsumed &&
t.ExpiresAt > DateTime.UtcNow)
?? throw new InvalidOperationException("Invalid or expired token");
if (confirmation.TryCount >= confirmation.MaxTries)
throw new InvalidOperationException("Too many attempts");
confirmation.TryCount++;
if (confirmation.Code != code)
{
await db.SaveChangesAsync();
return false;
}
confirmation.IsConsumed = true;
confirmation.ConsumedAt = DateTime.UtcNow;
if (confirmation.UserId.HasValue)
{
var user = await db.Users.FindAsync(confirmation.UserId.Value);
if (user != null)
{
user.PhoneVerified = true;
user.PhoneVerifiedAt = DateTime.UtcNow;
}
}
await db.SaveChangesAsync();
return true;
}
public async Task RequestPasswordResetAsync(string tenantSlug, string? email, string? phone)
{
var tenant = await db.Tenants.FirstOrDefaultAsync(t => t.Slug == tenantSlug && t.DeletedAt == null);
if (tenant == null) return;
User? user = null;
if (!string.IsNullOrEmpty(email))
user = await db.Users.FirstOrDefaultAsync(u => u.TenantId == tenant.Id && u.Email == email && u.DeletedAt == null);
else if (!string.IsNullOrEmpty(phone))
user = await db.Users.FirstOrDefaultAsync(u => u.TenantId == tenant.Id && u.PhoneNumber == phone && u.DeletedAt == null);
if (user == null) return;
await CreateConfirmationTokenAsync(user.Id, tenant.Id, TokenPurpose.PasswordReset, email ?? phone!, null);
}
public async Task<bool> ConfirmPasswordResetAsync(string token, string newPassword)
{
var tokenHash = tokenService.HashToken(token);
var confirmation = await db.ConfirmationTokens
.FirstOrDefaultAsync(t =>
t.TokenHash == tokenHash &&
t.Purpose == TokenPurpose.PasswordReset &&
!t.IsConsumed &&
t.ExpiresAt > DateTime.UtcNow)
?? throw new InvalidOperationException("Invalid or expired token");
confirmation.IsConsumed = true;
confirmation.ConsumedAt = DateTime.UtcNow;
if (confirmation.UserId.HasValue)
{
var user = await db.Users.FindAsync(confirmation.UserId.Value);
if (user != null)
{
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(newPassword);
user.PasswordSetAt = DateTime.UtcNow;
user.LastPasswordResetDate = DateTime.UtcNow;
// Revoke all sessions
await db.UserSessions
.Where(s => s.UserId == user.Id && s.RevokedAt == null)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.RevokedAt, DateTime.UtcNow));
}
}
await db.SaveChangesAsync();
return true;
}
public async Task ChangePasswordAsync(Guid userId, string currentPassword, string newPassword)
{
var user = await db.Users.FindAsync(userId)
?? throw new KeyNotFoundException("User not found");
if (string.IsNullOrEmpty(user.PasswordHash) || !BCrypt.Net.BCrypt.Verify(currentPassword, user.PasswordHash))
throw new UnauthorizedAccessException("Current password is incorrect");
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(newPassword);
user.PasswordSetAt = DateTime.UtcNow;
user.LastPasswordResetDate = DateTime.UtcNow;
await db.SaveChangesAsync();
}
public async Task<MfaSetupResponse> SetupMfaAsync(Guid userId, string factorType, string? label)
{
if (!Enum.TryParse<Domain.Enums.MfaFactorType>(factorType, true, out var type))
throw new ArgumentException("Invalid factor type");
if (type == Domain.Enums.MfaFactorType.TOTP)
{
var secret = KeyGeneration.GenerateRandomKey(20);
var base32Secret = Base32Encoding.ToString(secret);
var user = await db.Users.FindAsync(userId)!;
var factor = new MfaFactor
{
UserId = userId,
FactorType = type,
SecretEncrypted = base32Secret,
Label = label ?? user?.Email ?? "FlatRender",
};
db.MfaFactors.Add(factor);
await db.SaveChangesAsync();
var qrLabel = Uri.EscapeDataString($"FlatRender:{user?.Email ?? userId.ToString()}");
var qrSecret = Uri.EscapeDataString(base32Secret);
var qrUrl = $"otpauth://totp/{qrLabel}?secret={qrSecret}&issuer=FlatRender";
return new MfaSetupResponse(factor.Id, base32Secret, qrUrl, null);
}
throw new NotImplementedException($"Factor type {factorType} not yet supported");
}
public async Task<bool> VerifyMfaAsync(Guid userId, Guid factorId, string code)
{
var factor = await db.MfaFactors.FirstOrDefaultAsync(m => m.Id == factorId && m.UserId == userId)
?? throw new KeyNotFoundException("MFA factor not found");
if (!VerifyTotp(factor.SecretEncrypted!, code)) return false;
factor.IsVerified = true;
factor.IsPrimary = true;
factor.LastUsedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return true;
}
public async Task<AuthTokensResponse> ChallengeMfaAsync(string mfaToken, string code)
{
var (userId, tenantId) = ValidateMfaToken(mfaToken);
var factor = await db.MfaFactors.FirstOrDefaultAsync(m => m.UserId == userId && m.IsVerified && m.IsPrimary)
?? throw new UnauthorizedAccessException("No verified MFA factor");
if (!VerifyTotp(factor.SecretEncrypted!, code))
throw new UnauthorizedAccessException("Invalid MFA code");
factor.LastUsedAt = DateTime.UtcNow;
var user = await db.Users.FindAsync(userId)!
?? throw new UnauthorizedAccessException("User not found");
var tenant = await db.Tenants.FindAsync(tenantId)!
?? throw new UnauthorizedAccessException("Tenant not found");
return await CreateSessionAsync(user, tenant, null, null, null);
}
public async Task SubscribePushAsync(Guid userId, Guid tenantId, string endpoint, string p256dh, string auth, string? userAgent)
{
var existing = await db.PushSubscriptions.FirstOrDefaultAsync(p => p.UserId == userId && p.Endpoint == endpoint);
if (existing != null)
{
existing.IsActive = true;
existing.FailureCount = 0;
}
else
{
db.PushSubscriptions.Add(new PushSubscription
{
UserId = userId,
TenantId = tenantId,
Endpoint = endpoint,
P256dhKey = p256dh,
AuthKey = auth,
UserAgent = userAgent,
});
}
await db.SaveChangesAsync();
}
public async Task UnsubscribePushAsync(Guid userId, string? endpoint)
{
if (string.IsNullOrEmpty(endpoint))
{
await db.PushSubscriptions
.Where(p => p.UserId == userId)
.ExecuteUpdateAsync(p => p.SetProperty(x => x.IsActive, false));
}
else
{
await db.PushSubscriptions
.Where(p => p.UserId == userId && p.Endpoint == endpoint)
.ExecuteUpdateAsync(p => p.SetProperty(x => x.IsActive, false));
}
}
// ── Private helpers ──────────────────────────────────────────────────
private async Task<AuthTokensResponse> CreateSessionAsync(
User user, Tenant tenant,
string? deviceId, string? deviceName, string? ipAddress,
Guid? replacingSessionId = null)
{
var accessToken = tokenService.GenerateAccessToken(user, tenant);
var refreshToken = tokenService.GenerateRefreshToken();
var refreshHash = tokenService.HashToken(refreshToken);
var session = new UserSession
{
UserId = user.Id,
TenantId = tenant.Id,
RefreshTokenHash = refreshHash,
DeviceId = deviceId,
DeviceName = deviceName,
IpAddress = ipAddress,
ExpiresAt = DateTime.UtcNow.AddDays(_refreshTokenDays),
};
db.UserSessions.Add(session);
await db.SaveChangesAsync();
var userResponse = MapUserResponse(user);
var tenantResponse = MapTenantResponse(tenant);
return new AuthTokensResponse(accessToken, refreshToken, "Bearer", 15 * 60, userResponse, tenantResponse);
}
/// <summary>Issue a session for an externally-authenticated (OAuth/social) user.</summary>
public Task<AuthTokensResponse> IssueOAuthSessionAsync(User user, Tenant tenant, string? deviceName, string? ipAddress)
=> CreateSessionAsync(user, tenant, null, deviceName, ipAddress);
private async Task CreateConfirmationTokenAsync(
Guid userId, Guid tenantId, TokenPurpose purpose, string identifier, string? ipAddress)
{
var rawToken = tokenService.GenerateRefreshToken();
var tokenHash = tokenService.HashToken(rawToken);
var code = Random.Shared.Next(100000, 999999).ToString();
db.ConfirmationTokens.Add(new ConfirmationToken
{
UserId = userId,
TenantId = tenantId,
Purpose = purpose,
Identifier = identifier,
TokenHash = tokenHash,
Code = code,
RequestIp = ipAddress,
ExpiresAt = DateTime.UtcNow.AddHours(24),
});
await db.SaveChangesAsync();
// TODO: send code via email/SMS
}
private string GenerateMfaToken(Guid userId, Guid tenantId)
{
// Short-lived token encoding userId+tenantId for the MFA challenge flow
var payload = $"{userId}:{tenantId}:{DateTime.UtcNow.AddMinutes(10).Ticks}";
var bytes = System.Text.Encoding.UTF8.GetBytes(payload);
return Convert.ToBase64String(bytes);
}
private (Guid userId, Guid tenantId) ValidateMfaToken(string mfaToken)
{
try
{
var payload = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(mfaToken));
var parts = payload.Split(':');
if (parts.Length != 3) throw new UnauthorizedAccessException("Invalid MFA token");
var expiry = new DateTime(long.Parse(parts[2]));
if (expiry < DateTime.UtcNow) throw new UnauthorizedAccessException("MFA token expired");
return (Guid.Parse(parts[0]), Guid.Parse(parts[1]));
}
catch (Exception ex) when (ex is not UnauthorizedAccessException)
{
throw new UnauthorizedAccessException("Invalid MFA token");
}
}
private static bool VerifyTotp(string base32Secret, string code)
{
try
{
var secretBytes = Base32Encoding.ToBytes(base32Secret);
var totp = new Totp(secretBytes);
return totp.VerifyTotp(code, out _, new VerificationWindow(1, 1));
}
catch
{
return false;
}
}
internal static UserResponse MapUserResponse(User u) => new(
u.Id, u.TenantId, u.Email, u.EmailVerified,
u.PhoneNumber, u.PhoneVerified, u.FullName, u.AvatarUrl,
u.IsAdmin, u.IsTenantAdmin, u.RegisterMode.ToString(),
u.LastActiveDate, u.BalanceMinor, u.AffiliateBalanceMinor,
u.LoyaltyScore, u.DailyRemainRenderCount, u.MaxDailyRenderCount,
u.ParallelRenderingCeiling, u.UsedStorageBytes, u.RegisterDate
);
internal static TenantResponse MapTenantResponse(Tenant t) => new(
t.Id, t.Slug, t.Name, t.Kind.ToString(), t.Status.ToString(),
t.CustomDomain, t.DomainVerified, t.ContactEmail,
t.MaxUsers, t.MaxStorageGb, t.MonthlyRenderQty,
t.TrialEndsAt, t.CreatedAt
);
}
public class MfaRequiredException(string mfaToken, string message) : Exception(message)
{
public string MfaToken { get; } = mfaToken;
}