diff --git a/Media.JoshHeaps.Net/Api/AuthApi.cs b/Media.JoshHeaps.Net/Api/AuthApi.cs new file mode 100644 index 0000000..5a4cfed --- /dev/null +++ b/Media.JoshHeaps.Net/Api/AuthApi.cs @@ -0,0 +1,119 @@ +using Media.JoshHeaps.Net.Models; +using Media.JoshHeaps.Net.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace Media.JoshHeaps.Net.Api; + +[ApiController] +[Route("api/auth")] +public class AuthApi : ControllerBase +{ + private readonly AuthService _authService; + private readonly IConfiguration _configuration; + + public AuthApi(AuthService authService, IConfiguration configuration) + { + _authService = authService; + _configuration = configuration; + } + + [HttpPost("login")] + public async Task Login([FromBody] LoginRequest request) + { + if (string.IsNullOrWhiteSpace(request.EmailOrUsername) || string.IsNullOrWhiteSpace(request.Password)) + { + return BadRequest(new { error = "Email/username and password are required" }); + } + + var (success, error, userInfo) = await _authService.LoginAsync(request.EmailOrUsername, request.Password); + + if (!success || userInfo == null) + { + return Unauthorized(new { error = error ?? "Invalid credentials" }); + } + + // Generate JWT token + var token = GenerateJwtToken(userInfo); + + return Ok(new LoginResponse + { + Token = token, + User = userInfo + }); + } + + [Authorize] + [HttpPost("validate")] + public IActionResult Validate() + { + // If we get here, the JWT token is valid + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + var email = User.FindFirst(ClaimTypes.Email)?.Value; + var username = User.FindFirst(ClaimTypes.Name)?.Value; + var emailVerified = User.FindFirst("EmailVerified")?.Value; + + if (string.IsNullOrEmpty(userId)) + { + return Unauthorized(new { error = "Invalid token" }); + } + + return Ok(new + { + user = new UserLoginInfo + { + Id = long.Parse(userId), + Email = email ?? string.Empty, + Username = username ?? string.Empty, + EmailVerified = bool.Parse(emailVerified ?? "false"), + IsActive = true + } + }); + } + + private string GenerateJwtToken(UserLoginInfo user) + { + var jwtKey = _configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key not configured"); + var jwtIssuer = _configuration["Jwt:Issuer"] ?? throw new InvalidOperationException("JWT Issuer not configured"); + var jwtAudience = _configuration["Jwt:Audience"] ?? throw new InvalidOperationException("JWT Audience not configured"); + var jwtExpiryDays = int.Parse(_configuration["Jwt:ExpiryInDays"] ?? "30"); + + var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)); + var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Email, user.Email), + new Claim(ClaimTypes.Name, user.Username), + new Claim("EmailVerified", user.EmailVerified.ToString()) + }; + + var token = new JwtSecurityToken( + issuer: jwtIssuer, + audience: jwtAudience, + claims: claims, + expires: DateTime.UtcNow.AddDays(jwtExpiryDays), + signingCredentials: credentials + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} + +// Request/Response models +public class LoginRequest +{ + public string EmailOrUsername { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; +} + +public class LoginResponse +{ + public string Token { get; set; } = string.Empty; + public UserLoginInfo User { get; set; } = new(); +} diff --git a/Media.JoshHeaps.Net/Api/MediaApi.cs b/Media.JoshHeaps.Net/Api/MediaApi.cs index ac9ab9c..780445e 100644 --- a/Media.JoshHeaps.Net/Api/MediaApi.cs +++ b/Media.JoshHeaps.Net/Api/MediaApi.cs @@ -1,5 +1,6 @@ using Media.JoshHeaps.Net.Services; using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; namespace Media.JoshHeaps.Net.Api; @@ -17,15 +18,14 @@ public class MediaApi : ControllerBase [HttpGet("load")] public async Task LoadMedia([FromQuery] int offset = 0, [FromQuery] int limit = 20) { - // Get user ID from session - var userIdString = HttpContext.Session.GetString("UserId"); - if (string.IsNullOrEmpty(userIdString)) + // Get user ID from JWT claims or session + var userId = GetUserIdFromAuth(); + if (userId == null) { return Unauthorized(); } - var userId = long.Parse(userIdString); - var media = await _mediaService.GetUserMediaAsync(userId, offset, limit); + var media = await _mediaService.GetUserMediaAsync(userId.Value, offset, limit); return Ok(media); } @@ -33,24 +33,22 @@ public class MediaApi : ControllerBase [HttpGet("image/{mediaId}")] public async Task GetImage(long mediaId) { - // Get user ID from session - var userIdString = HttpContext.Session.GetString("UserId"); - if (string.IsNullOrEmpty(userIdString)) + // Get user ID from JWT claims or session + var userId = GetUserIdFromAuth(); + if (userId == null) { return Unauthorized(new { error = "Not authenticated" }); } - var userId = long.Parse(userIdString); - // Get media metadata (includes ownership check) - var media = await _mediaService.GetMediaByIdAsync(mediaId, userId); + var media = await _mediaService.GetMediaByIdAsync(mediaId, userId.Value); if (media == null) { return NotFound(new { error = "Image not found or access denied" }); } // Get decrypted image data - var imageData = await _mediaService.GetDecryptedMediaDataAsync(mediaId, userId); + var imageData = await _mediaService.GetDecryptedMediaDataAsync(mediaId, userId.Value); if (imageData == null) { return NotFound(new { error = "Image file not found" }); @@ -59,4 +57,26 @@ public class MediaApi : ControllerBase // Return image with proper content type return File(imageData, media.MimeType); } + + /// + /// Gets user ID from either JWT claims (for API) or session (for web) + /// + private long? GetUserIdFromAuth() + { + // First try JWT claims (for mobile/API authentication) + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId)) + { + return jwtUserId; + } + + // Fall back to session (for web authentication) + var userIdString = HttpContext.Session.GetString("UserId"); + if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId)) + { + return sessionUserId; + } + + return null; + } } diff --git a/Media.JoshHeaps.Net/Media.JoshHeaps.Net.csproj b/Media.JoshHeaps.Net/Media.JoshHeaps.Net.csproj index d11ba80..5c5215a 100644 --- a/Media.JoshHeaps.Net/Media.JoshHeaps.Net.csproj +++ b/Media.JoshHeaps.Net/Media.JoshHeaps.Net.csproj @@ -10,6 +10,7 @@ + diff --git a/Media.JoshHeaps.Net/Program.cs b/Media.JoshHeaps.Net/Program.cs index 45621d7..f698e38 100644 --- a/Media.JoshHeaps.Net/Program.cs +++ b/Media.JoshHeaps.Net/Program.cs @@ -1,5 +1,8 @@ using Media.JoshHeaps.Net; using Media.JoshHeaps.Net.Services; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using System.Text; var builder = WebApplication.CreateBuilder(args); @@ -22,6 +25,30 @@ builder.Services.AddSession(options => options.Cookie.IsEssential = true; }); +// Add JWT authentication +var jwtKey = builder.Configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key not configured"); +var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? throw new InvalidOperationException("JWT Issuer not configured"); +var jwtAudience = builder.Configuration["Jwt:Audience"] ?? throw new InvalidOperationException("JWT Audience not configured"); + +builder.Services.AddAuthentication(options => +{ + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; +}) +.AddJwtBearer(options => +{ + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtIssuer, + ValidAudience = jwtAudience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)) + }; +}); + var app = builder.Build(); // Configure the HTTP request pipeline. diff --git a/Media.JoshHeaps.Net/appsettings.json b/Media.JoshHeaps.Net/appsettings.json index 5c719a9..b05998d 100644 --- a/Media.JoshHeaps.Net/appsettings.json +++ b/Media.JoshHeaps.Net/appsettings.json @@ -7,6 +7,11 @@ }, "AllowedHosts": "*", "AppUrl": "https://media.joshheaps.net", + "Jwt": { + "Issuer": "https://media.joshheaps.net", + "Audience": "https://media.joshheaps.net", + "ExpiryInDays": 30 + }, "Email": { "SmtpHost": "smtp.gmail.com", "SmtpPort": "587",