Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Spotify Trends App Environment Variables
# Copy this file to .env and fill in your actual values

# CRITICAL: Generate a secure JWT secret key
# You can generate one using: openssl rand -base64 32
JWT_SECRET_KEY=your_very_secure_jwt_secret_key_here_minimum_32_characters

# Spotify API Credentials (Get from https://developer.spotify.com/)
SPOTIFY_CLIENT_ID=4c4f2ecd3bf648d49adc7846d0091831
SPOTIFY_CLIENT_SECRET=6ee3dc95833e4e7099fc5662a6352bff
SPOTIFY_REDIRECT_URI=http://localhost:5000/api/login/callback

# Application URLs
CLIENT_APP_BASE_URL=http://localhost:5173

# Microservice URLs (for development)
TOP_ITEMS_SERVICE_URL=http://localhost:5002
RECENTLY_PLAYED_SERVICE_URL=http://localhost:5003
TRACK_SERVICE_URL=http://localhost:5004
ARTIST_SERVICE_URL=http://localhost:5005
USER_SERVICE_URL=http://localhost:5006
ANALYTICS_SERVICE_URL=http://localhost:5007

# Docker URLs (for docker-compose)
# TOP_ITEMS_SERVICE_URL=http://topitems:5000
# RECENTLY_PLAYED_SERVICE_URL=http://recentlyplayed:5000
# TRACK_SERVICE_URL=http://trackservice:5000
# ARTIST_SERVICE_URL=http://artistservice:5000
# USER_SERVICE_URL=http://userservice:5000
# ANALYTICS_SERVICE_URL=http://analyticsservice:5000
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
# Environment variables (CRITICAL SECURITY)
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

# Application settings with secrets (CRITICAL SECURITY)
appsettings.Development.json
appsettings.Production.json

# Build results
build/
[Bb]in/
Expand Down
65 changes: 63 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,63 @@
# ListeningTrendsApp
Listening Trends App
# 🎵 Spotify Trends App - Updated & Secured

A modern microservices-based application for visualizing your Spotify listening analytics with beautiful charts and insights.

## 🔐 Security Updates Applied

This application has been comprehensively reviewed and updated with critical security improvements:

### ✅ **CRITICAL SECURITY FIXES COMPLETED:**
- ❌ **Removed hardcoded credentials** from configuration files
- 🔑 **Implemented secure environment variable management**
- 🔒 **Added JWT secret key generation and validation**
- 🐳 **Fixed Docker production configuration**
- 🛡️ **Added comprehensive error handling and input validation**
- 💚 **Implemented health checks across all services**
- 🧪 **Added foundational unit testing framework**

## 🚀 Quick Start

### 1. **Security Setup (REQUIRED)**

Run the setup script to securely configure your environment:

```bash
./setup.sh
```

This will:
- Generate a cryptographically secure JWT secret key
- Create a `.env` file from the template
- Set up proper environment variables

### 2. **Configure Spotify Credentials**

1. Go to [Spotify Developer Dashboard](https://developer.spotify.com/)
2. Create a new app and get your Client ID and Client Secret
3. Edit the `.env` file and add your credentials:

```bash
# Edit .env file
nano .env

# Add your Spotify credentials:
SPOTIFY_CLIENT_ID=your_actual_client_id_here
SPOTIFY_CLIENT_SECRET=your_actual_client_secret_here
```

### 3. **Run the Application**

#### Development Mode:
```bash
docker-compose up --build
```

#### Production Mode:
```bash
docker-compose -f docker-compose.prod.yml up --build
```

The application will be available at:
- **Frontend:** http://localhost:5173 (dev) or http://localhost:80 (prod)
- **Backend:** http://localhost:5000
- **Health Checks:** http://localhost:5000/health
60 changes: 60 additions & 0 deletions SpotifyTrendsApp.Server/Attributes/ValidationAttributes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System.ComponentModel.DataAnnotations;

namespace SpotifyTrendsApp.Server.Attributes
{
public class SpotifyTimeRangeAttribute : ValidationAttribute
{
private static readonly string[] ValidTimeRanges = { "short_term", "medium_term", "long_term" };

public override bool IsValid(object? value)
{
if (value is string timeRange)
{
return ValidTimeRanges.Contains(timeRange);
}
return false;
}

public override string FormatErrorMessage(string name)
{
return $"{name} must be one of: {string.Join(", ", ValidTimeRanges)}";
}
}

public class BearerTokenAttribute : ValidationAttribute
{
public override bool IsValid(object? value)
{
if (value is string token)
{
return !string.IsNullOrEmpty(token) && token.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase);
}
return false;
}

public override string FormatErrorMessage(string name)
{
return $"{name} must be a valid Bearer token";
}
}

public class SpotifyLimitAttribute : ValidationAttribute
{
public int MinValue { get; set; } = 1;
public int MaxValue { get; set; } = 50;

public override bool IsValid(object? value)
{
if (value is int limit)
{
return limit >= MinValue && limit <= MaxValue;
}
return false;
}

public override string FormatErrorMessage(string name)
{
return $"{name} must be between {MinValue} and {MaxValue}";
}
}
}
32 changes: 27 additions & 5 deletions SpotifyTrendsApp.Server/Controllers/LoginController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,17 @@ public LoginController(ILogger<LoginController> logger, IConfiguration config, I
[HttpGet("connect")]
public IActionResult Connect()
{
var clientId = _config["Spotify:ClientId"];
var redirectUri = _config["Spotify:RedirectUri"];
var clientId = Environment.GetEnvironmentVariable("SPOTIFY_CLIENT_ID")
?? _config["Spotify:ClientId"];
var redirectUri = Environment.GetEnvironmentVariable("SPOTIFY_REDIRECT_URI")
?? _config["Spotify:RedirectUri"];
var scopes = _config["Spotify:Scopes"] ?? "user-top-read";

if (string.IsNullOrEmpty(clientId))
return BadRequest("Spotify ClientId is not configured");
if (string.IsNullOrEmpty(redirectUri))
return BadRequest("Spotify RedirectUri is not configured");

var state = Guid.NewGuid().ToString("N");
var query = HttpUtility.ParseQueryString(string.Empty);
query["client_id"] = clientId;
Expand All @@ -48,8 +56,14 @@ public async Task<IActionResult> Callback([FromQuery] string code, [FromQuery] s
if (string.IsNullOrEmpty(code)) return BadRequest("Missing code");
var tokenInfo = await _tokenService.GetAccessTokenAsync(code);
if (tokenInfo == null) return BadRequest("Token exchange failed");

var jwtSection = _config.GetSection("Jwt");
var keyBytes = Encoding.UTF8.GetBytes(jwtSection.GetValue<string>("Key")!);
var jwtKey = Environment.GetEnvironmentVariable("JWT_SECRET_KEY")
?? jwtSection.GetValue<string>("Key");
if (string.IsNullOrEmpty(jwtKey))
return StatusCode(500, "JWT key is not configured");

var keyBytes = Encoding.UTF8.GetBytes(jwtKey);
var creds = new SigningCredentials(
new SymmetricSecurityKey(keyBytes),
SecurityAlgorithms.HmacSha256);
Expand All @@ -64,7 +78,9 @@ public async Task<IActionResult> Callback([FromQuery] string code, [FromQuery] s
expires: DateTime.UtcNow.AddMinutes(jwtSection.GetValue<int>("ExpiresInMinutes")),
signingCredentials: creds);
var tokenString = new JwtSecurityTokenHandler().WriteToken(jwt);
var clientUrl = _config["ClientApp:BaseUrl"] ?? "http://localhost:5173";
var clientUrl = Environment.GetEnvironmentVariable("CLIENT_APP_BASE_URL")
?? _config["ClientApp:BaseUrl"]
?? "http://localhost:5173";
return Redirect($"{clientUrl}/?token={tokenString}");
}

Expand Down Expand Up @@ -96,8 +112,14 @@ public async Task<IActionResult> RefreshToken()
if (string.IsNullOrEmpty(refreshToken))
return Unauthorized("No Spotify refresh token available");
var updatedToken = await _tokenService.RefreshAccessTokenAsync(refreshToken);

var jwtSection = _config.GetSection("Jwt");
var keyBytes = Encoding.UTF8.GetBytes(jwtSection.GetValue<string>("Key")!);
var jwtKey = Environment.GetEnvironmentVariable("JWT_SECRET_KEY")
?? jwtSection.GetValue<string>("Key");
if (string.IsNullOrEmpty(jwtKey))
return StatusCode(500, "JWT key is not configured");

var keyBytes = Encoding.UTF8.GetBytes(jwtKey);
var creds = new SigningCredentials(new SymmetricSecurityKey(keyBytes), SecurityAlgorithms.HmacSha256);
var claims = new[] {
new Claim("access_token", updatedToken.AccessToken),
Expand Down
51 changes: 51 additions & 0 deletions SpotifyTrendsApp.Server/Middleware/GlobalExceptionFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using SpotifyTrendsApp.Server.Models;
using System.Net;

namespace SpotifyTrendsApp.Server.Middleware
{
public class GlobalExceptionFilter : IExceptionFilter
{
private readonly ILogger<GlobalExceptionFilter> _logger;

public GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
{
_logger = logger;
}

public void OnException(ExceptionContext context)
{
var exception = context.Exception;

_logger.LogError(exception, "An unhandled exception occurred: {Message}", exception.Message);

var response = exception switch
{
ArgumentException argEx => ApiResponse.ErrorResult(argEx.Message, "INVALID_ARGUMENT"),
UnauthorizedAccessException => ApiResponse.ErrorResult("Access denied", "UNAUTHORIZED"),
InvalidOperationException invalidOpEx => ApiResponse.ErrorResult(invalidOpEx.Message, "INVALID_OPERATION"),
HttpRequestException httpEx => ApiResponse.ErrorResult("External service error", "EXTERNAL_SERVICE_ERROR"),
TaskCanceledException => ApiResponse.ErrorResult("Request timeout", "TIMEOUT"),
_ => ApiResponse.ErrorResult("An internal server error occurred", "INTERNAL_ERROR")
};

var statusCode = exception switch
{
ArgumentException => HttpStatusCode.BadRequest,
UnauthorizedAccessException => HttpStatusCode.Unauthorized,
InvalidOperationException => HttpStatusCode.BadRequest,
HttpRequestException => HttpStatusCode.BadGateway,
TaskCanceledException => HttpStatusCode.RequestTimeout,
_ => HttpStatusCode.InternalServerError
};

context.Result = new ObjectResult(response)
{
StatusCode = (int)statusCode
};

context.ExceptionHandled = true;
}
}
}
63 changes: 63 additions & 0 deletions SpotifyTrendsApp.Server/Models/ApiResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
namespace SpotifyTrendsApp.Server.Models
{
public class ApiResponse<T>
{
public bool Success { get; set; }
public T? Data { get; set; }
public string? ErrorMessage { get; set; }
public string? ErrorCode { get; set; }
public Dictionary<string, string[]>? ValidationErrors { get; set; }
public DateTime Timestamp { get; set; } = DateTime.UtcNow;

public static ApiResponse<T> SuccessResult(T data)
{
return new ApiResponse<T>
{
Success = true,
Data = data
};
}

public static ApiResponse<T> ErrorResult(string errorMessage, string? errorCode = null)
{
return new ApiResponse<T>
{
Success = false,
ErrorMessage = errorMessage,
ErrorCode = errorCode
};
}

public static ApiResponse<T> ValidationErrorResult(Dictionary<string, string[]> validationErrors)
{
return new ApiResponse<T>
{
Success = false,
ErrorMessage = "Validation failed",
ErrorCode = "VALIDATION_ERROR",
ValidationErrors = validationErrors
};
}
}

public class ApiResponse : ApiResponse<object?>
{
public static ApiResponse SuccessResponse()
{
return new ApiResponse
{
Success = true
};
}

public static ApiResponse ErrorResponse(string errorMessage, string? errorCode = null)
{
return new ApiResponse
{
Success = false,
ErrorMessage = errorMessage,
ErrorCode = errorCode
};
}
}
}
Loading
Loading