diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index c421243..7211c7f 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -31,15 +31,11 @@ jobs: with: fetch-depth: 1 - - name: Setup GitHub App Token - uses: anthropics/claude-code-action@v1 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - - name: Run Claude Code Review id: claude-review uses: anthropics/claude-code-action@v1 with: + github_token: ${{ secrets.GITHUB_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} prompt: | Please review this pull request and provide feedback on: diff --git a/techblog_cms/settings.py b/techblog_cms/settings.py index 7c89841..d9b8416 100644 --- a/techblog_cms/settings.py +++ b/techblog_cms/settings.py @@ -67,15 +67,11 @@ print(f"IS_TESTING: {IS_TESTING}") if IS_TESTING: - # Testing uses explicit env vars to keep CI simple + # Testing uses SQLite for simplicity DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': os.environ.get('POSTGRES_DB', 'techblogdb'), - 'USER': os.environ.get('POSTGRES_USER', 'techblog'), - 'PASSWORD': os.environ.get('POSTGRES_PASSWORD', 'techblogpass'), - 'HOST': os.environ.get('POSTGRES_HOST', 'db'), - 'PORT': os.environ.get('POSTGRES_PORT', '5432'), + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', } } # Disable CSRF for testing diff --git a/techblog_cms/tests/test_login_security.py b/techblog_cms/tests/test_login_security.py new file mode 100644 index 0000000..75e3a39 --- /dev/null +++ b/techblog_cms/tests/test_login_security.py @@ -0,0 +1,93 @@ +from django.test import TestCase, Client +from django.contrib.auth.models import User +from django.urls import reverse +import time + + +class LoginSecurityTests(TestCase): + """Test cases for login security to prevent username enumeration attacks.""" + + def setUp(self): + self.client = Client() + # Create a test user + self.test_user = User.objects.create_user( + username='testuser', + password='testpass123' + ) + self.login_url = reverse('login') # assuming the login view is named 'login' + + def test_login_error_message_consistency(self): + """Test that error messages are generic for both existing and non-existing users.""" + # Test with non-existing user + response_nonexistent = self.client.post(self.login_url, { + 'username': 'nonexistentuser', + 'password': 'wrongpassword' + }) + + # Test with existing user but wrong password + response_wrong_password = self.client.post(self.login_url, { + 'username': 'testuser', + 'password': 'wrongpassword' + }) + + # Both should return the same generic error message + self.assertEqual(response_nonexistent.status_code, 401) + self.assertEqual(response_wrong_password.status_code, 401) + + # Check that error messages are the same and generic + error_msg_nonexistent = response_nonexistent.context.get('error', '') + error_msg_wrong_password = response_wrong_password.context.get('error', '') + + # Both should have the same generic error message + self.assertEqual(error_msg_nonexistent, error_msg_wrong_password) + + # Error message should be generic (not revealing username existence) + self.assertEqual(error_msg_nonexistent, 'Invalid credentials') + + def test_successful_login(self): + """Test that successful login still works correctly.""" + response = self.client.post(self.login_url, { + 'username': 'testuser', + 'password': 'testpass123' + }) + + # Should redirect to dashboard on successful login + self.assertEqual(response.status_code, 302) + self.assertRedirects(response, reverse('dashboard')) + + def test_login_timing_consistency(self): + """Test that login attempts take similar time regardless of username existence.""" + # This is a basic timing test - in a real security audit, more sophisticated timing analysis would be needed + + times_nonexistent = [] + times_wrong_password = [] + + # Run multiple attempts to get average timing + for _ in range(5): + # Time non-existent user login + start_time = time.time() + self.client.post(self.login_url, { + 'username': 'nonexistentuser', + 'password': 'wrongpassword' + }) + times_nonexistent.append(time.time() - start_time) + + # Time existing user with wrong password + start_time = time.time() + self.client.post(self.login_url, { + 'username': 'testuser', + 'password': 'wrongpassword' + }) + times_wrong_password.append(time.time() - start_time) + + # Calculate averages + avg_nonexistent = sum(times_nonexistent) / len(times_nonexistent) + avg_wrong_password = sum(times_wrong_password) / len(times_wrong_password) + + # The difference should be minimal (less than 50ms) + # This is a very basic test - sophisticated timing attacks require more precise measurements + time_difference = abs(avg_nonexistent - avg_wrong_password) + self.assertLess(time_difference, 0.05, + f"Timing difference too large: {time_difference:.3f}s. " + f"Avg nonexistent: {avg_nonexistent:.3f}s, " + f"Avg wrong password: {avg_wrong_password:.3f}s") \ No newline at end of file diff --git a/techblog_cms/views.py b/techblog_cms/views.py index b721436..1934775 100644 --- a/techblog_cms/views.py +++ b/techblog_cms/views.py @@ -55,15 +55,15 @@ def login_view(request): if request.method == 'POST': username = request.POST.get('username') password = request.POST.get('password') - print(f"Username: {username}, Password: {password}") + + # Use Django's authenticate function but ensure consistent behavior user = authenticate(username=username, password=password) - print(f"User: {user}") if user is not None: - print("Login successful") login(request, user) return redirect('dashboard') else: - return render(request, 'login.html', {"error": "ユーザー名またはパスワードが違います。"}, status=401) + # Generic error message to prevent username enumeration + return render(request, 'login.html', {"error": "Invalid credentials"}, status=401) return render(request, 'login.html')