diff --git a/.env.example b/.env.example index 5be19dc..a4165d1 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,20 @@ -# Backend Environment Variables -SECRET_KEY=your_super_secret_django_key +# Copy to .env and adjust. Every value is optional in development. + +# Django +SECRET_KEY=change-me-to-a-long-random-string DEBUG=True -DATABASE_URL=postgres://username:password@localhost:5432/vaultify_db ALLOWED_HOSTS=localhost,127.0.0.1 +# CSRF_TRUSTED_ORIGINS=https://vault.example.com + +# Database (defaults to SQLite at backend/db.sqlite3 when unset) +# DATABASE_URL=postgres://username:password@localhost:5432/vaultify_db + +# Encryption key(s) for vault secrets. Generate one with: +# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +# Comma-separate several keys to rotate: the first encrypts, all can decrypt. +# VAULT_ENCRYPTION_KEY= -# Frontend Environment Variables (if using Vite) -VITE_API_BASE_URL=http://localhost:8000/api +# Security +VAULT_SESSION_TIMEOUT=1800 +LOGIN_MAX_ATTEMPTS=5 +LOGIN_LOCKOUT_SECONDS=300 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e934909 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: backend/requirements.txt + - run: pip install -r requirements.txt + - run: python manage.py check + - run: python manage.py makemigrations --check --dry-run + - run: python manage.py test vault diff --git a/.gitignore b/.gitignore index 33ffd6c..9306a7f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,11 @@ db.sqlite3 media/ staticfiles/ +.venv/ +venv/ +env/ +.coverage +htmlcov/ +.DS_Store +.idea/ +.vscode/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d81c260..3091cdd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,8 @@ Thank you for considering contributing to Vaultify! We welcome contributions fro ## Code Style - Follow PEP8 for Python -- Use ES6+ syntax for JavaScript/React +- Use modern (ES2020+) vanilla JavaScript β€” no build step +- Run `python manage.py test vault` before opening a PR - Keep functions modular and well-commented - Ensure your code is linted and tested diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0cb4807 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +WORKDIR /app + +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt "psycopg[binary]>=3.1" + +COPY backend/ . +RUN DEBUG=False SECRET_KEY=build-only python manage.py collectstatic --noinput + +EXPOSE 8000 +CMD ["sh", "-c", "python manage.py migrate --noinput && gunicorn vaultify.wsgi:application --bind 0.0.0.0:8000 --workers 3"] diff --git a/README.md b/README.md index c552b70..fc9e142 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,139 @@ # Vaultify πŸ” -Vaultify is a secure, RESTful web app built using Django + DRF for storing encrypted personal data or secrets. PostgreSQL backs the data, and the encryption layer ensures high-grade protection of your information. +**An encrypted personal vault built with Django.** Store passwords, payment cards, API keys, Wi-Fi credentials and private notes, all encrypted at rest. It also includes a password generator, a security health audit, a full activity log and a REST API. -## Features -- πŸ” Encrypted Vault Storage -- πŸ§ͺ Unit Tested Models -- πŸ“¦ Django REST Framework API -- 🧠 Clean architecture with MVC pattern -- 🐘 PostgreSQL DB -- πŸ§ͺ Easy-to-use dev environment -- 🧰 Extensible and scalable folder structure +![Dashboard](docs/screenshots/dashboard.png) -## πŸš€ Tech Stack +## ✨ Features -Backend | Database | Auth | Misc | ---------|----------|------|------| -Django | PostgreSQL | JWT | REST, CORS | +| | | +|---|---| +| πŸ”’ **Encryption at rest** | Secrets and notes are encrypted with Fernet (AES-128-CBC + HMAC-SHA256). Several keys can be configured, so you can rotate keys without downtime. | +| 🩺 **Security health** | A 0–100 score that flags weak, reused and stale passwords. Reuse is detected with keyed HMAC fingerprints, so the audit never decrypts your vault. | +| πŸͺ„ **Password generator** | Uses Python's `secrets` module to make passwords or passphrases, with live entropy scoring. | +| 🧾 **Activity log** | Records every sign-in, failed sign-in, reveal, copy, edit, delete and export, with the IP address and time. | +| πŸ›‘οΈ **Hardened by default** | Brute-force lockout, sessions that auto-lock when idle, CSRF protection, strict cookies and data scoped to its owner. Secrets never appear in page HTML: they are fetched on demand and hidden again after 20 seconds. | +| πŸ—‚οΈ **Organisation** | Categories, tags, favourites, instant search and keyboard shortcuts (`/` search, `n` new item). | +| πŸŒ— **Polished UI** | Responsive dark and light themes, built with no frontend build step. | +| πŸ”Œ **REST API** | Django REST Framework with JWT auth, pagination, throttling and filtering. | +| πŸ“¦ **Export** | Download a decrypted JSON backup after confirming your password. | + + + + + + + + + +
Item detailPassword generator
Security healthLanding page
-## πŸ› οΈ Setup Instructions +## πŸš€ Quick start + +Requires Python 3.11 or newer. ```bash -cd backend -python -m venv env -source env/bin/activate +git clone https://github.com/QuantumCoderrr/Vaultify.git +cd Vaultify/backend +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate pip install -r requirements.txt python manage.py migrate +python manage.py seed_demo # optional: demo / vaultify-demo-2024 python manage.py runserver ``` -πŸ“‚ Environment Variables -Copy .env.example to .env and set appropriate keys. +Open http://127.0.0.1:8000. You can create an account, or sign in with the demo user that `seed_demo` creates. + +Create an admin user with `python manage.py createsuperuser`. The admin site is at `/admin/` and shows item metadata only, never decrypted secrets. +### Docker + +```bash +docker build -t vaultify . +docker run -p 8000:8000 -e SECRET_KEY=change-me -e DEBUG=False -e ALLOWED_HOSTS=localhost -e SECURE_SSL_REDIRECT=False vaultify ``` -SECRET_KEY=your_secret_key -DATABASE_URL=your_postgres_url -DEBUG=True + +## βš™οΈ Configuration + +Settings are read from environment variables or a `.env` file. See [`.env.example`](.env.example) for the full list. + +| Variable | Default | Purpose | +|---|---|---| +| `SECRET_KEY` | dev key (only when `DEBUG=True`) | Django secret key. **Required in production.** | +| `DEBUG` | `True` | Turn off in production to enable HTTPS-only cookies, HSTS and the SSL redirect. | +| `DATABASE_URL` | SQLite | e.g. `postgres://user:pass@host:5432/vaultify` (install `psycopg[binary]`). | +| `VAULT_ENCRYPTION_KEY` | derived from `SECRET_KEY` | Comma-separated Fernet keys. The first one encrypts; all of them can decrypt. | +| `VAULT_SESSION_TIMEOUT` | `1800` | Seconds of inactivity before the vault auto-locks. | +| `LOGIN_MAX_ATTEMPTS` / `LOGIN_LOCKOUT_SECONDS` | `5` / `300` | Brute-force protection. | + +Generate an encryption key: + +```bash +python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" ``` -## 🀝 Contributing -We welcome PRs and suggestions! Check out [CONTRIBUTING.md](CONTRIBUTING.md) to get started. +> ⚠️ Set `VAULT_ENCRYPTION_KEY` explicitly in production and back it up. If you lose it, you lose the data. If you leave it unset, changing `SECRET_KEY` makes existing secrets unreadable. + +## πŸ”Œ REST API + +Base URL: `/api/v1/`. Authenticate with a JWT (`Authorization: Bearer `) or a session cookie. + +| Method | Endpoint | Description | +|---|---|---| +| `POST` | `auth/register/` | Create an account | +| `POST` | `auth/token/` Β· `auth/token/refresh/` | Get or refresh a JWT pair | +| `GET` `POST` | `items/` | List (`?q=`, `?category=`, `?favorite=true`) or create items | +| `GET` `PUT` `PATCH` `DELETE` | `items/{id}/` | Manage one item (the secret is write-only) | +| `GET` | `items/{id}/reveal/` | Decrypt the secret and notes (the reveal is logged) | +| `GET` | `items/health/` | Security health report | +| `GET` | `activity/` | Your activity log | +| `POST` | `tools/generate/` Β· `tools/strength/` | Generate a password, or score one | + +```bash +TOKEN=$(curl -s -X POST localhost:8000/api/v1/auth/token/ \ + -d username=demo -d password=vaultify-demo-2024 | python -c "import sys,json;print(json.load(sys.stdin)['access'])") +curl -H "Authorization: Bearer $TOKEN" localhost:8000/api/v1/items/ +``` + +## 🧱 Architecture + +``` +backend/ +β”œβ”€β”€ vaultify/ # project settings & root URLs +└── vault/ + β”œβ”€β”€ models.py # VaultItem (encrypted fields) & ActivityLog + β”œβ”€β”€ crypto.py # Fernet / MultiFernet helpers, key rotation + β”œβ”€β”€ passwords.py # generator + entropy-based strength estimator + β”œβ”€β”€ security.py # weak / reused / stale analysis + β”œβ”€β”€ views.py, forms.py # server-rendered web UI + β”œβ”€β”€ api.py, serializers.py, api_urls.py # REST API + β”œβ”€β”€ signals.py # login / logout / failed-login auditing + β”œβ”€β”€ templates/ static/ # UI (vanilla CSS + JS, no build step) + β”œβ”€β”€ management/commands/seed_demo.py + └── tests/ # crypto, models, API and view tests +``` + +**Security model.** Encryption happens on the server, so the database alone never holds plaintext secrets. Whoever controls both the server and its encryption key can decrypt them. This is the same trust model as most self-hosted apps. Vaultify is not a zero-knowledge (client-side encryption) design. + +## πŸ§ͺ Tests + +```bash +cd backend +python manage.py test vault +``` + +CI runs the test suite on Python 3.11–3.13 for every push (see `.github/workflows/ci.yml`). + +## 🀝 Contributing + +PRs and suggestions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) to get started. + +## πŸ’¬ Code of Conduct + +Kindness comes first. Please read our [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) before you start. + +## πŸ“„ License -## πŸ’¬ Code of Conduct -Kindness comes first. Read our [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) before diving in. +[MIT](LICENSE) diff --git a/backend/requirements.txt b/backend/requirements.txt index 97538fc..2966d35 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,5 +1,10 @@ -Django>=4.2 -djangorestframework -psycopg2-binary -cryptography - +Django>=5.2,<6.0 +djangorestframework>=3.15 +djangorestframework-simplejwt>=5.3 +cryptography>=42.0 +python-dotenv>=1.0 +dj-database-url>=2.1 +whitenoise>=6.6 +gunicorn>=22.0 +# Optional: install to use PostgreSQL via DATABASE_URL +# psycopg[binary]>=3.1 diff --git a/backend/vault/admin.py b/backend/vault/admin.py index a3cc034..e98e02f 100644 --- a/backend/vault/admin.py +++ b/backend/vault/admin.py @@ -1,5 +1,26 @@ from django.contrib import admin -from .models import VaultItem -admin.site.register(VaultItem) +from .models import ActivityLog, VaultItem + +@admin.register(VaultItem) +class VaultItemAdmin(admin.ModelAdmin): + """Admins can manage metadata but never see decrypted secrets.""" + + list_display = ("title", "user", "category", "favorite", "strength_score", "updated_at") + list_filter = ("category", "favorite") + search_fields = ("title", "username", "url", "user__username") + exclude = ("encrypted_secret", "encrypted_notes") + readonly_fields = ("secret_fingerprint", "strength_score", "created_at", "updated_at", + "secret_changed_at", "last_accessed_at") + + +@admin.register(ActivityLog) +class ActivityLogAdmin(admin.ModelAdmin): + list_display = ("created_at", "user", "action", "item_title", "ip_address") + list_filter = ("action",) + search_fields = ("user__username", "item_title", "ip_address") + readonly_fields = [f.name for f in ActivityLog._meta.fields] + + def has_add_permission(self, request): + return False diff --git a/backend/vault/api.py b/backend/vault/api.py new file mode 100644 index 0000000..7696b24 --- /dev/null +++ b/backend/vault/api.py @@ -0,0 +1,114 @@ +"""REST API (``/api/v1/``).""" + +from django.db.models import Q +from django.utils import timezone +from rest_framework import generics, permissions, status, viewsets +from rest_framework.decorators import action, api_view, permission_classes +from rest_framework.response import Response + +from .models import ActivityLog, VaultItem +from .passwords import estimate_strength, generate_passphrase, generate_password +from .permissions import IsOwner +from .security import analyze +from .serializers import ( + ActivityLogSerializer, + PasswordGeneratorSerializer, + RegisterSerializer, + VaultItemRevealSerializer, + VaultItemSerializer, +) + + +class VaultItemViewSet(viewsets.ModelViewSet): + """CRUD for the authenticated user's vault items. + + Supports ``?q=`` search, ``?category=`` and ``?favorite=true`` filters. + Plaintext secrets are only returned by ``GET /items/{id}/reveal/``. + """ + + serializer_class = VaultItemSerializer + permission_classes = [permissions.IsAuthenticated, IsOwner] + + def get_queryset(self): + qs = VaultItem.objects.filter(user=self.request.user) + params = self.request.query_params + if q := params.get("q"): + qs = qs.filter(Q(title__icontains=q) | Q(username__icontains=q) + | Q(url__icontains=q) | Q(tags__icontains=q)) + if category := params.get("category"): + qs = qs.filter(category=category) + if params.get("favorite") in {"1", "true", "True"}: + qs = qs.filter(favorite=True) + return qs + + def perform_create(self, serializer): + item = serializer.save(user=self.request.user) + ActivityLog.record(self.request, ActivityLog.Action.CREATE, item) + + def perform_update(self, serializer): + item = serializer.save() + ActivityLog.record(self.request, ActivityLog.Action.UPDATE, item) + + def perform_destroy(self, instance): + ActivityLog.record(self.request, ActivityLog.Action.DELETE, instance) + instance.delete() + + @action(detail=True, methods=["get"]) + def reveal(self, request, pk=None): + item = self.get_object() + item.last_accessed_at = timezone.now() + item.save(update_fields=["last_accessed_at"]) + ActivityLog.record(request, ActivityLog.Action.REVEAL, item) + return Response(VaultItemRevealSerializer(item).data) + + @action(detail=False, methods=["get"]) + def health(self, request): + report = analyze(VaultItem.objects.filter(user=request.user)) + brief = lambda items: [{"id": i.id, "title": i.title} for i in items] # noqa: E731 + return Response({ + "score": report["score"], + "grade": report["grade"], + "total": report["total"], + "weak": brief(report["weak"]), + "reused": brief(report["reused"]), + "stale": brief(report["stale"]), + }) + + +class ActivityLogList(generics.ListAPIView): + serializer_class = ActivityLogSerializer + + def get_queryset(self): + return ActivityLog.objects.filter(user=self.request.user) + + +class RegisterView(generics.CreateAPIView): + serializer_class = RegisterSerializer + permission_classes = [permissions.AllowAny] + + +@api_view(["POST"]) +@permission_classes([permissions.AllowAny]) +def generate(request): + """Generate a password or passphrase. Body fields are all optional.""" + serializer = PasswordGeneratorSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + opts = serializer.validated_data + if opts["mode"] == "passphrase": + value = generate_passphrase(words=opts["words"], separator=opts["separator"]) + else: + value = generate_password( + length=opts["length"], lowercase=opts["lowercase"], uppercase=opts["uppercase"], + digits=opts["digits"], symbols=opts["symbols"], + exclude_ambiguous=opts["exclude_ambiguous"], + ) + return Response({"password": value, "strength": estimate_strength(value).as_dict()}) + + +@api_view(["POST"]) +@permission_classes([permissions.AllowAny]) +def strength(request): + password = request.data.get("password", "") + if not isinstance(password, str): + return Response({"detail": "password must be a string"}, status=status.HTTP_400_BAD_REQUEST) + return Response(estimate_strength(password).as_dict()) diff --git a/backend/vault/api_urls.py b/backend/vault/api_urls.py new file mode 100644 index 0000000..8c250aa --- /dev/null +++ b/backend/vault/api_urls.py @@ -0,0 +1,18 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView + +from . import api + +router = DefaultRouter() +router.register("items", api.VaultItemViewSet, basename="item") + +urlpatterns = [ + path("auth/register/", api.RegisterView.as_view(), name="api-register"), + path("auth/token/", TokenObtainPairView.as_view(), name="api-token"), + path("auth/token/refresh/", TokenRefreshView.as_view(), name="api-token-refresh"), + path("activity/", api.ActivityLogList.as_view(), name="api-activity"), + path("tools/generate/", api.generate, name="api-generate"), + path("tools/strength/", api.strength, name="api-strength"), + path("", include(router.urls)), +] diff --git a/backend/vault/apps.py b/backend/vault/apps.py index 02ca4d7..8a8489e 100644 --- a/backend/vault/apps.py +++ b/backend/vault/apps.py @@ -1,6 +1,10 @@ from django.apps import AppConfig + class VaultConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'vault' + default_auto_field = "django.db.models.BigAutoField" + name = "vault" + verbose_name = "Vault" + def ready(self): + from . import signals # noqa: F401 diff --git a/backend/vault/context_processors.py b/backend/vault/context_processors.py new file mode 100644 index 0000000..b24addc --- /dev/null +++ b/backend/vault/context_processors.py @@ -0,0 +1,7 @@ +from django.conf import settings + + +def vault_globals(request): + return { + "VAULT_SESSION_TIMEOUT": settings.SESSION_COOKIE_AGE, + } diff --git a/backend/vault/crypto.py b/backend/vault/crypto.py new file mode 100644 index 0000000..c0dba79 --- /dev/null +++ b/backend/vault/crypto.py @@ -0,0 +1,65 @@ +""" +Symmetric encryption helpers for vault secrets. + +Secrets are encrypted at rest with Fernet (AES-128-CBC + HMAC-SHA256). +``MultiFernet`` is used so keys can be rotated: the first configured key +encrypts, every configured key may decrypt. +""" + +import base64 +import hashlib +from functools import lru_cache + +from cryptography.fernet import Fernet, InvalidToken, MultiFernet +from django.conf import settings + + +class DecryptionError(Exception): + """Raised when stored ciphertext cannot be decrypted with any known key.""" + + +def generate_key() -> str: + """Return a new random Fernet key as a string.""" + return Fernet.generate_key().decode() + + +def _derive_key_from_secret(secret: str) -> bytes: + digest = hashlib.pbkdf2_hmac( + "sha256", secret.encode(), b"vaultify-field-encryption", 200_000 + ) + return base64.urlsafe_b64encode(digest) + + +@lru_cache(maxsize=8) +def _build_fernet(keys: tuple[str, ...], secret_key: str) -> MultiFernet: + if keys: + fernets = [Fernet(k.encode() if isinstance(k, str) else k) for k in keys] + else: + fernets = [Fernet(_derive_key_from_secret(secret_key))] + return MultiFernet(fernets) + + +def get_fernet() -> MultiFernet: + return _build_fernet(tuple(settings.VAULT_ENCRYPTION_KEYS), settings.SECRET_KEY) + + +def encrypt(plaintext: str) -> str: + if plaintext is None or plaintext == "": + return "" + return get_fernet().encrypt(plaintext.encode()).decode() + + +def decrypt(token: str) -> str: + if not token: + return "" + try: + return get_fernet().decrypt(token.encode()).decode() + except InvalidToken as exc: + raise DecryptionError("Unable to decrypt vault data with the configured keys.") from exc + + +def rotate(token: str) -> str: + """Re-encrypt a token with the current primary key.""" + if not token: + return "" + return get_fernet().rotate(token.encode()).decode() diff --git a/backend/vault/forms.py b/backend/vault/forms.py new file mode 100644 index 0000000..6f7eb2d --- /dev/null +++ b/backend/vault/forms.py @@ -0,0 +1,108 @@ +from django import forms +from django.contrib.auth import get_user_model +from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, UserCreationForm + +from .models import VaultItem + + +class StyledFormMixin: + """Adds the design-system CSS class to every widget.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field in self.fields.values(): + widget = field.widget + if isinstance(widget, forms.CheckboxInput): + widget.attrs.setdefault("class", "checkbox") + else: + widget.attrs.setdefault("class", "input") + + +class SignUpForm(StyledFormMixin, UserCreationForm): + email = forms.EmailField(required=False, help_text="Optional.") + + class Meta(UserCreationForm.Meta): + model = get_user_model() + fields = ("username", "email") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["username"].widget.attrs.update(placeholder="satoshi", autofocus=True) + self.fields["email"].widget.attrs.update(placeholder="you@example.com") + self.fields["password1"].widget.attrs.update(placeholder="At least 10 characters", + **{"data-strength": "true"}) + self.fields["password2"].widget.attrs.update(placeholder="Repeat password") + + +class LoginForm(StyledFormMixin, AuthenticationForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["username"].widget.attrs.update(placeholder="Username") + self.fields["password"].widget.attrs.update(placeholder="Master password") + + +class StyledPasswordChangeForm(StyledFormMixin, PasswordChangeForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["new_password1"].widget.attrs["data-strength"] = "true" + + +class VaultItemForm(StyledFormMixin, forms.ModelForm): + secret = forms.CharField( + required=False, + strip=False, + label="Password / secret", + widget=forms.PasswordInput(render_value=True, attrs={ + "autocomplete": "new-password", "data-strength": "true", "spellcheck": "false", + }), + ) + notes = forms.CharField( + required=False, + strip=False, + widget=forms.Textarea(attrs={"rows": 4, "placeholder": "Recovery codes, PINs, anything private…"}), + ) + + class Meta: + model = VaultItem + fields = ["title", "category", "username", "url", "tags", "favorite"] + widgets = { + "title": forms.TextInput(attrs={"placeholder": "e.g. GitHub", "autofocus": True}), + "username": forms.TextInput(attrs={"placeholder": "Email or username", "autocomplete": "off"}), + "url": forms.URLInput(attrs={"placeholder": "https://"}), + "tags": forms.TextInput(attrs={"placeholder": "work, dev, finance"}), + } + labels = {"url": "Website", "username": "Username / email", "favorite": "Pin to favourites"} + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance and self.instance.pk: + self.fields["secret"].initial = self.instance.secret + self.fields["notes"].initial = self.instance.notes + + def clean_tags(self): + tags = [t.strip().lower() for t in self.cleaned_data["tags"].split(",") if t.strip()] + return ", ".join(dict.fromkeys(tags)) + + def save(self, commit=True): + item = super().save(commit=False) + item.secret = self.cleaned_data.get("secret", "") + item.notes = self.cleaned_data.get("notes", "") + if commit: + item.save() + return item + + +class DeleteAccountForm(forms.Form): + password = forms.CharField( + widget=forms.PasswordInput(attrs={"class": "input", "placeholder": "Confirm your password"}) + ) + + def __init__(self, user, *args, **kwargs): + self.user = user + super().__init__(*args, **kwargs) + + def clean_password(self): + password = self.cleaned_data["password"] + if not self.user.check_password(password): + raise forms.ValidationError("Incorrect password.") + return password diff --git a/backend/vault/management/__init__.py b/backend/vault/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/vault/management/commands/__init__.py b/backend/vault/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/vault/management/commands/seed_demo.py b/backend/vault/management/commands/seed_demo.py new file mode 100644 index 0000000..1601a48 --- /dev/null +++ b/backend/vault/management/commands/seed_demo.py @@ -0,0 +1,73 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand +from django.utils import timezone + +from vault.models import VaultItem +from vault.passwords import generate_passphrase, generate_password + +DEMO_ITEMS = [ + dict(title="GitHub", category="login", username="dev@example.com", url="https://github.com", + tags="work, dev", favorite=True, secret=None), + dict(title="Google Workspace", category="login", username="me@example.com", + url="https://accounts.google.com", tags="work", favorite=True, secret=None), + dict(title="Netflix", category="login", username="family@example.com", url="https://netflix.com", + tags="personal, streaming", secret="sunshine2024"), + dict(title="Spotify", category="login", username="family@example.com", url="https://spotify.com", + tags="personal, streaming", secret="sunshine2024"), + dict(title="Old forum account", category="login", username="coolcat99", url="https://example.org", + tags="personal", secret="Tr0ub4dor&3", age_days=420), + dict(title="Visa Platinum", category="card", username="J. Doe", tags="finance", favorite=True, + secret="4242 4242 4242 4242 Β· 12/29 Β· CVV 123", + notes="Customer support: +1 800 555 0100\nPIN stored separately."), + dict(title="Production API key", category="api_key", username="payments-service", + url="https://dashboard.stripe.com", tags="work, dev", + secret="sk_demo_51HxQ9vKZ2pL7wN3mR8tY4uB6cD0eF1gH"), + dict(title="Home Wi-Fi", category="wifi", username="Orbit-5G", tags="home", secret=None, passphrase=True), + dict(title="Recovery codes", category="note", tags="security", + notes="GitHub 2FA recovery codes:\n a1b2-c3d4\n e5f6-g7h8\n i9j0-k1l2"), + dict(title="Passport", category="identity", username="Jane Doe", tags="travel", + secret="X1234567", notes="Expires 2031-06-01"), +] + + +class Command(BaseCommand): + help = "Create a demo user with a realistic, pre-populated vault." + + def add_arguments(self, parser): + parser.add_argument("--username", default="demo") + parser.add_argument("--password", default="vaultify-demo-2024") + parser.add_argument("--reset", action="store_true", help="Delete the demo user's existing items first.") + + def handle(self, *args, username, password, reset, **options): + User = get_user_model() + user, created = User.objects.get_or_create(username=username, defaults={"email": f"{username}@example.com"}) + user.set_password(password) + user.save() + + if reset or created: + user.vault_items.all().delete() + elif user.vault_items.exists(): + self.stdout.write(self.style.WARNING("Demo user already has items; use --reset to recreate them.")) + return + + for spec in DEMO_ITEMS: + spec = dict(spec) + age = spec.pop("age_days", None) + secret = spec.pop("secret", None) + notes = spec.pop("notes", "") + if spec.pop("passphrase", False): + secret = generate_passphrase(4) + elif secret is None and spec["category"] != "note": + secret = generate_password(22) + item = VaultItem(user=user, **spec) + item.secret = secret or "" + item.notes = notes + if age: + item.secret_changed_at = timezone.now() - timedelta(days=age) + item.save() + + self.stdout.write(self.style.SUCCESS( + f"Demo vault ready β†’ username: {username} password: {password}" + )) diff --git a/backend/vault/migrations/0001_initial.py b/backend/vault/migrations/0001_initial.py new file mode 100644 index 0000000..37d224d --- /dev/null +++ b/backend/vault/migrations/0001_initial.py @@ -0,0 +1,151 @@ +# Generated by Django 5.2.19 on 2026-11-04 10:30 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="VaultItem", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=255)), + ( + "category", + models.CharField( + choices=[ + ("login", "Login"), + ("card", "Payment card"), + ("note", "Secure note"), + ("api_key", "API key"), + ("wifi", "Wi-Fi"), + ("identity", "Identity"), + ], + default="login", + max_length=20, + ), + ), + ("username", models.CharField(blank=True, max_length=255)), + ("url", models.URLField(blank=True, max_length=500)), + ( + "tags", + models.CharField( + blank=True, help_text="Comma-separated tags", max_length=255 + ), + ), + ("favorite", models.BooleanField(default=False)), + ("encrypted_secret", models.TextField(blank=True, default="")), + ("encrypted_notes", models.TextField(blank=True, default="")), + ( + "secret_fingerprint", + models.CharField( + blank=True, db_index=True, editable=False, max_length=64 + ), + ), + ( + "strength_score", + models.PositiveSmallIntegerField( + blank=True, editable=False, null=True + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "secret_changed_at", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("last_accessed_at", models.DateTimeField(blank=True, null=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="vault_items", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-favorite", "-updated_at"], + }, + ), + migrations.CreateModel( + name="ActivityLog", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "action", + models.CharField( + choices=[ + ("login", "Signed in"), + ("login_failed", "Failed sign-in"), + ("logout", "Signed out"), + ("create", "Created item"), + ("update", "Updated item"), + ("delete", "Deleted item"), + ("reveal", "Revealed secret"), + ("copy", "Copied secret"), + ("export", "Exported vault"), + ("password_change", "Changed account password"), + ], + max_length=20, + ), + ), + ("item_title", models.CharField(blank=True, max_length=255)), + ("ip_address", models.GenericIPAddressField(blank=True, null=True)), + ("user_agent", models.CharField(blank=True, max_length=255)), + ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="vault_activity", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "item", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="vault.vaultitem", + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + ), + migrations.AddIndex( + model_name="vaultitem", + index=models.Index( + fields=["user", "category"], name="vault_vault_user_id_bebb06_idx" + ), + ), + ] diff --git a/backend/vault/migrations/__init__.py b/backend/vault/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/vault/models.py b/backend/vault/models.py index a8335cc..a56f6d5 100644 --- a/backend/vault/models.py +++ b/backend/vault/models.py @@ -1,12 +1,160 @@ +import hashlib +import hmac + +from django.conf import settings from django.db import models -from django.contrib.auth.models import User +from django.urls import reverse +from django.utils import timezone + +from . import crypto +from .passwords import estimate_strength + class VaultItem(models.Model): - user = models.ForeignKey(User, on_delete=models.CASCADE) + class Category(models.TextChoices): + LOGIN = "login", "Login" + CARD = "card", "Payment card" + NOTE = "note", "Secure note" + API_KEY = "api_key", "API key" + WIFI = "wifi", "Wi-Fi" + IDENTITY = "identity", "Identity" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="vault_items" + ) title = models.CharField(max_length=255) - encrypted_data = models.TextField() + category = models.CharField(max_length=20, choices=Category.choices, default=Category.LOGIN) + username = models.CharField(max_length=255, blank=True) + url = models.URLField(max_length=500, blank=True) + tags = models.CharField(max_length=255, blank=True, help_text="Comma-separated tags") + favorite = models.BooleanField(default=False) + + # Encrypted at rest. Never read these directly; use .secret / .notes. + encrypted_secret = models.TextField(blank=True, default="") + encrypted_notes = models.TextField(blank=True, default="") + + # Derived metadata so security analysis never needs to decrypt everything. + secret_fingerprint = models.CharField(max_length=64, blank=True, db_index=True, editable=False) + strength_score = models.PositiveSmallIntegerField(null=True, blank=True, editable=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + secret_changed_at = models.DateTimeField(default=timezone.now) + last_accessed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-favorite", "-updated_at"] + indexes = [models.Index(fields=["user", "category"])] def __str__(self): return self.title + def get_absolute_url(self): + return reverse("vault:item_detail", args=[self.pk]) + + # --- encrypted properties ------------------------------------------------ + @property + def secret(self) -> str: + return crypto.decrypt(self.encrypted_secret) + + @secret.setter + def secret(self, value: str): + value = value or "" + new_fp = self.fingerprint(value) if value else "" + if new_fp != self.secret_fingerprint: + self.secret_changed_at = timezone.now() + self.encrypted_secret = crypto.encrypt(value) + self.secret_fingerprint = new_fp + self.strength_score = estimate_strength(value).score if value else None + + @property + def notes(self) -> str: + return crypto.decrypt(self.encrypted_notes) + + @notes.setter + def notes(self, value: str): + self.encrypted_notes = crypto.encrypt(value or "") + + @staticmethod + def fingerprint(value: str) -> str: + """Keyed hash used to detect reused secrets without decrypting them.""" + return hmac.new(settings.SECRET_KEY.encode(), value.encode(), hashlib.sha256).hexdigest() + + # --- helpers ------------------------------------------------------------- + @property + def tag_list(self) -> list[str]: + return [t.strip() for t in self.tags.split(",") if t.strip()] + + @property + def domain(self) -> str: + from urllib.parse import urlparse + + return urlparse(self.url).netloc.removeprefix("www.") if self.url else "" + + @property + def initials(self) -> str: + return (self.title[:2] or "?").upper() + + @property + def age_days(self) -> int: + return (timezone.now() - self.secret_changed_at).days + + @property + def is_weak(self) -> bool: + return self.strength_score is not None and self.strength_score < 2 + + +class ActivityLog(models.Model): + class Action(models.TextChoices): + LOGIN = "login", "Signed in" + LOGIN_FAILED = "login_failed", "Failed sign-in" + LOGOUT = "logout", "Signed out" + CREATE = "create", "Created item" + UPDATE = "update", "Updated item" + DELETE = "delete", "Deleted item" + REVEAL = "reveal", "Revealed secret" + COPY = "copy", "Copied secret" + EXPORT = "export", "Exported vault" + PASSWORD_CHANGE = "password_change", "Changed account password" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="vault_activity" + ) + action = models.CharField(max_length=20, choices=Action.choices) + item = models.ForeignKey(VaultItem, null=True, blank=True, on_delete=models.SET_NULL) + item_title = models.CharField(max_length=255, blank=True) + ip_address = models.GenericIPAddressField(null=True, blank=True) + user_agent = models.CharField(max_length=255, blank=True) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"{self.user} {self.get_action_display()} {self.item_title}".strip() + + @classmethod + def record(cls, request, action, item=None, user=None): + user = user or request.user + forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "") + ip = forwarded.split(",")[0].strip() if forwarded else request.META.get("REMOTE_ADDR") + return cls.objects.create( + user=user, + action=action, + item=item, + item_title=item.title if item else "", + ip_address=ip or None, + user_agent=request.META.get("HTTP_USER_AGENT", "")[:255], + ) + + @property + def tone(self) -> str: + return { + "login_failed": "danger", + "delete": "danger", + "reveal": "warning", + "copy": "warning", + "export": "warning", + "create": "success", + "login": "success", + }.get(self.action, "info") diff --git a/backend/vault/passwords.py b/backend/vault/passwords.py new file mode 100644 index 0000000..72d529c --- /dev/null +++ b/backend/vault/passwords.py @@ -0,0 +1,142 @@ +"""Password generation and strength analysis utilities.""" + +import math +import re +import secrets +import string +from dataclasses import dataclass + +AMBIGUOUS = set("Il1O0o") +SYMBOLS = "!@#$%^&*()-_=+[]{};:,.?/" + +WORDLIST = ( + "amber anchor apple arrow atlas aurora autumn badge bamboo beacon berry blaze " + "breeze bridge brook cabin cactus canyon carbon cedar cinder citrus cliff cloud " + "comet copper coral cosmos crane crystal dawn delta desert drift dune eagle echo " + "ember falcon fern fjord flame forest fossil frost galaxy garden glacier granite " + "harbor hazel horizon island ivory jade jasmine jungle kayak kernel lagoon lantern " + "lemon lotus lunar maple marble meadow meteor mint mirror monsoon mosaic nebula " + "nectar nova oasis ocean olive onyx orbit orchid otter palm panda pebble pepper " + "pixel planet plume polar prism quartz quill radar raven reef ripple river rocket " + "saffron sapphire sequoia shadow sierra silver solar spark spruce summit sunset " + "talon thunder tide timber topaz tulip tundra velvet violet vortex walnut willow " + "winter zenith zephyr" +).split() + + +def generate_password( + length: int = 20, + lowercase: bool = True, + uppercase: bool = True, + digits: bool = True, + symbols: bool = True, + exclude_ambiguous: bool = False, +) -> str: + """Generate a cryptographically secure random password. + + At least one character from every selected class is guaranteed. + """ + length = max(4, min(int(length), 128)) + pools = [] + if lowercase: + pools.append(string.ascii_lowercase) + if uppercase: + pools.append(string.ascii_uppercase) + if digits: + pools.append(string.digits) + if symbols: + pools.append(SYMBOLS) + if not pools: + pools.append(string.ascii_lowercase) + if exclude_ambiguous: + pools = ["".join(c for c in pool if c not in AMBIGUOUS) for pool in pools] + + alphabet = "".join(pools) + chars = [secrets.choice(pool) for pool in pools] + chars += [secrets.choice(alphabet) for _ in range(length - len(chars))] + secrets.SystemRandom().shuffle(chars) + return "".join(chars) + + +def generate_passphrase(words: int = 5, separator: str = "-", capitalize: bool = True, + add_number: bool = True) -> str: + words = max(3, min(int(words), 12)) + chosen = [secrets.choice(WORDLIST) for _ in range(words)] + if capitalize: + chosen = [w.capitalize() for w in chosen] + if add_number: + chosen.append(str(secrets.randbelow(100))) + return separator.join(chosen) + + +@dataclass +class Strength: + score: int # 0..4 + label: str + entropy: float + feedback: list[str] + + def as_dict(self) -> dict: + return { + "score": self.score, + "label": self.label, + "entropy": round(self.entropy, 1), + "feedback": self.feedback, + } + + +LABELS = ["Very weak", "Weak", "Fair", "Strong", "Excellent"] +COMMON = { + "password", "123456", "12345678", "qwerty", "letmein", "welcome", "admin", + "iloveyou", "monkey", "dragon", "football", "abc123", "111111", "sunshine", + "princess", "master", "shadow", "baseball", "superman", "trustno1", +} + + +def estimate_strength(password: str) -> Strength: + """Rough entropy-based strength estimate with actionable feedback.""" + if not password: + return Strength(0, LABELS[0], 0.0, ["Enter a password."]) + + pool = 0 + if re.search(r"[a-z]", password): + pool += 26 + if re.search(r"[A-Z]", password): + pool += 26 + if re.search(r"\d", password): + pool += 10 + if re.search(r"[^A-Za-z0-9]", password): + pool += 33 + entropy = len(password) * math.log2(pool) if pool else 0.0 + + feedback = [] + if len(password) < 12: + feedback.append("Use at least 12 characters.") + if not re.search(r"[A-Z]", password) or not re.search(r"[a-z]", password): + feedback.append("Mix upper and lower case letters.") + if not re.search(r"\d", password): + feedback.append("Add some numbers.") + if not re.search(r"[^A-Za-z0-9]", password): + feedback.append("Add symbols.") + if re.search(r"(.)\1{2,}", password): + entropy *= 0.75 + feedback.append("Avoid repeated characters.") + lowered = password.lower() + if any(w in lowered for w in COMMON | {"qwerty", "1234", "abcd"}): + entropy = min(entropy, 25) + feedback.append("Avoid common words and sequences.") + elif re.fullmatch(r"[A-Za-z]+\d{1,4}[^A-Za-z0-9]?", password): + entropy *= 0.6 + feedback.append("A word followed by a number is easy to guess.") + + if entropy < 28: + score = 0 + elif entropy < 40: + score = 1 + elif entropy < 60: + score = 2 + elif entropy < 80: + score = 3 + else: + score = 4 + return Strength(score, LABELS[score], entropy, feedback) diff --git a/backend/vault/permissions.py b/backend/vault/permissions.py index 10089eb..6c90776 100644 --- a/backend/vault/permissions.py +++ b/backend/vault/permissions.py @@ -1,6 +1,8 @@ from rest_framework import permissions + class IsOwner(permissions.BasePermission): - def has_object_permission(self, request, view, obj): - return obj.user == request.user + """Object-level permission: only the owner of a vault item may access it.""" + def has_object_permission(self, request, view, obj): + return obj.user_id == request.user.id diff --git a/backend/vault/security.py b/backend/vault/security.py new file mode 100644 index 0000000..8cc6597 --- /dev/null +++ b/backend/vault/security.py @@ -0,0 +1,47 @@ +"""Vault health analysis (weak / reused / stale secrets).""" + +from collections import Counter + +from django.conf import settings + +STALE_AFTER_DAYS = getattr(settings, "VAULT_STALE_AFTER_DAYS", 180) + + +def analyze(items) -> dict: + items = list(items) + with_secret = [i for i in items if i.secret_fingerprint] + counts = Counter(i.secret_fingerprint for i in with_secret) + + weak = [i for i in with_secret if i.is_weak] + reused = [i for i in with_secret if counts[i.secret_fingerprint] > 1] + stale = [i for i in with_secret if i.age_days >= STALE_AFTER_DAYS] + + total = len(with_secret) + if total: + flagged = {i.pk for i in weak} | {i.pk for i in reused} + stale_only = {i.pk for i in stale} - flagged + penalty = len(flagged) + 0.5 * len(stale_only) + score = round(100 * max(0.0, 1 - penalty / total)) + else: + score = 100 + + if score >= 85: + grade, tone = "Excellent", "success" + elif score >= 65: + grade, tone = "Good", "info" + elif score >= 40: + grade, tone = "Needs work", "warning" + else: + grade, tone = "At risk", "danger" + + return { + "score": score, + "grade": grade, + "tone": tone, + "total": total, + "weak": weak, + "reused": reused, + "stale": stale, + "strong_count": sum(1 for i in with_secret if (i.strength_score or 0) >= 3), + "stale_after_days": STALE_AFTER_DAYS, + } diff --git a/backend/vault/serializers.py b/backend/vault/serializers.py index 78b4ca5..5d03fa9 100644 --- a/backend/vault/serializers.py +++ b/backend/vault/serializers.py @@ -1,8 +1,98 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.password_validation import validate_password from rest_framework import serializers -from .models import VaultItem + +from .models import ActivityLog, VaultItem +from .passwords import LABELS + class VaultItemSerializer(serializers.ModelSerializer): + """Vault item without its plaintext secret (use the ``reveal`` action).""" + + secret = serializers.CharField(write_only=True, required=False, allow_blank=True, + trim_whitespace=False, style={"input_type": "password"}) + notes = serializers.CharField(write_only=True, required=False, allow_blank=True, + trim_whitespace=False) + strength = serializers.SerializerMethodField() + tag_list = serializers.ListField(read_only=True) + + class Meta: + model = VaultItem + fields = [ + "id", "title", "category", "username", "url", "tags", "tag_list", "favorite", + "secret", "notes", "strength", "created_at", "updated_at", + "secret_changed_at", "last_accessed_at", + ] + read_only_fields = ["created_at", "updated_at", "secret_changed_at", "last_accessed_at"] + + def get_strength(self, obj): + if obj.strength_score is None: + return None + return {"score": obj.strength_score, "label": LABELS[obj.strength_score]} + + def _apply_encrypted(self, instance, validated_data): + if "secret" in validated_data: + instance.secret = validated_data.pop("secret") + if "notes" in validated_data: + instance.notes = validated_data.pop("notes") + + def create(self, validated_data): + secret = validated_data.pop("secret", "") + notes = validated_data.pop("notes", "") + instance = VaultItem(**validated_data) + instance.secret = secret + instance.notes = notes + instance.save() + return instance + + def update(self, instance, validated_data): + self._apply_encrypted(instance, validated_data) + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + return instance + + +class VaultItemRevealSerializer(serializers.ModelSerializer): + secret = serializers.CharField(read_only=True) + notes = serializers.CharField(read_only=True) + class Meta: model = VaultItem - fields = '__all__' + fields = ["id", "title", "username", "url", "secret", "notes"] + + +class ActivityLogSerializer(serializers.ModelSerializer): + action_display = serializers.CharField(source="get_action_display", read_only=True) + + class Meta: + model = ActivityLog + fields = ["id", "action", "action_display", "item", "item_title", "ip_address", "created_at"] + + +class RegisterSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True, style={"input_type": "password"}) + + class Meta: + model = get_user_model() + fields = ["id", "username", "email", "password"] + + def validate(self, attrs): + user = get_user_model()(username=attrs.get("username"), email=attrs.get("email", "")) + validate_password(attrs["password"], user=user) + return attrs + + def create(self, validated_data): + return get_user_model().objects.create_user(**validated_data) + +class PasswordGeneratorSerializer(serializers.Serializer): + mode = serializers.ChoiceField(choices=["password", "passphrase"], default="password") + length = serializers.IntegerField(min_value=4, max_value=128, default=20) + words = serializers.IntegerField(min_value=3, max_value=12, default=5) + lowercase = serializers.BooleanField(default=True) + uppercase = serializers.BooleanField(default=True) + digits = serializers.BooleanField(default=True) + symbols = serializers.BooleanField(default=True) + exclude_ambiguous = serializers.BooleanField(default=False) + separator = serializers.CharField(max_length=3, default="-", allow_blank=True, trim_whitespace=False) diff --git a/backend/vault/signals.py b/backend/vault/signals.py new file mode 100644 index 0000000..febfca3 --- /dev/null +++ b/backend/vault/signals.py @@ -0,0 +1,27 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.signals import user_logged_in, user_logged_out, user_login_failed +from django.dispatch import receiver + +from .models import ActivityLog + + +@receiver(user_logged_in) +def on_login(sender, request, user, **kwargs): + if request is not None: + ActivityLog.record(request, ActivityLog.Action.LOGIN, user=user) + + +@receiver(user_logged_out) +def on_logout(sender, request, user, **kwargs): + if request is not None and user is not None: + ActivityLog.record(request, ActivityLog.Action.LOGOUT, user=user) + + +@receiver(user_login_failed) +def on_login_failed(sender, credentials, request=None, **kwargs): + if request is None: + return + username = credentials.get("username") + user = get_user_model().objects.filter(username=username).first() if username else None + if user is not None: + ActivityLog.record(request, ActivityLog.Action.LOGIN_FAILED, user=user) diff --git a/backend/vault/static/vault/css/app.css b/backend/vault/static/vault/css/app.css new file mode 100644 index 0000000..a1df8b8 --- /dev/null +++ b/backend/vault/static/vault/css/app.css @@ -0,0 +1,439 @@ +/* ========================================================================== + Vaultify design system + ========================================================================== */ + +:root { + --bg: #07080f; + --bg-elev: #0d1020; + --surface: rgba(255, 255, 255, 0.035); + --surface-2: rgba(255, 255, 255, 0.06); + --surface-solid: #11142a; + --border: rgba(255, 255, 255, 0.08); + --border-strong: rgba(255, 255, 255, 0.16); + --text: #eef0ff; + --text-muted: #9aa0c3; + --text-faint: #646a8f; + --primary: #7c6cff; + --primary-2: #5b8cff; + --accent: #22d3ee; + --grad: linear-gradient(135deg, #8b5cf6 0%, #6366f1 45%, #22d3ee 100%); + --success: #34d399; + --warning: #fbbf24; + --danger: #f87171; + --info: #60a5fa; + --radius-sm: 10px; + --radius: 14px; + --radius-lg: 22px; + --shadow: 0 10px 40px -12px rgba(0, 0, 0, 0.6); + --shadow-glow: 0 0 0 1px rgba(124, 108, 255, 0.35), 0 12px 40px -8px rgba(124, 108, 255, 0.45); + --font: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --sidebar-w: 260px; + color-scheme: dark; +} + +:root[data-theme="light"] { + --bg: #f5f6fb; + --bg-elev: #ffffff; + --surface: rgba(255, 255, 255, 0.8); + --surface-2: rgba(15, 23, 42, 0.045); + --surface-solid: #ffffff; + --border: rgba(15, 23, 42, 0.09); + --border-strong: rgba(15, 23, 42, 0.18); + --text: #0f1330; + --text-muted: #555b7e; + --text-faint: #8a8fae; + --shadow: 0 10px 30px -14px rgba(15, 23, 42, 0.25); + color-scheme: light; +} + +*, *::before, *::after { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; } +body { + margin: 0; + min-height: 100vh; + font-family: var(--font); + font-size: 15px; + line-height: 1.55; + color: var(--text); + background: var(--bg); + -webkit-font-smoothing: antialiased; +} +body::before { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background: + radial-gradient(900px 500px at 10% -10%, rgba(124, 108, 255, 0.18), transparent 60%), + radial-gradient(700px 500px at 100% 0%, rgba(34, 211, 238, 0.10), transparent 60%), + radial-gradient(800px 600px at 50% 120%, rgba(99, 102, 241, 0.10), transparent 60%); +} +:root[data-theme="light"] body::before { opacity: .6; } + +a { color: inherit; text-decoration: none; } +img, svg { display: block; } +h1, h2, h3, h4 { margin: 0; line-height: 1.2; letter-spacing: -0.02em; } +p { margin: 0; } +code, .mono { font-family: var(--mono); } +.icon { width: 18px; height: 18px; flex: none; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } +.icon-sm { width: 15px; height: 15px; } +.icon-lg { width: 24px; height: 24px; } +[hidden] { display: none !important; } +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } +.muted { color: var(--text-muted); } +.faint { color: var(--text-faint); } +.grad-text { background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; } +.stack { display: flex; flex-direction: column; gap: 16px; } +.row { display: flex; align-items: center; gap: 10px; } +.row-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.wrap { flex-wrap: wrap; } +.grow { flex: 1; min-width: 0; } +.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* ---------- Buttons ---------- */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + height: 40px; padding: 0 16px; + border-radius: var(--radius-sm); border: 1px solid var(--border); + background: var(--surface-2); color: var(--text); + font: 600 14px/1 var(--font); cursor: pointer; white-space: nowrap; + transition: transform .15s ease, background .2s, border-color .2s, box-shadow .2s; +} +.btn:hover { border-color: var(--border-strong); background: var(--surface); } +.btn:active { transform: translateY(1px); } +.btn:focus-visible, .input:focus-visible, .chip:focus-visible, .icon-btn:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } +.btn-primary { background: var(--grad); border-color: transparent; color: #fff; box-shadow: 0 8px 24px -10px rgba(124, 108, 255, .8); } +.btn-primary:hover { background: var(--grad); filter: brightness(1.08); box-shadow: var(--shadow-glow); } +.btn-danger { background: rgba(248, 113, 113, .12); border-color: rgba(248, 113, 113, .35); color: var(--danger); } +.btn-danger:hover { background: rgba(248, 113, 113, .2); border-color: var(--danger); } +.btn-ghost { background: transparent; border-color: transparent; } +.btn-ghost:hover { background: var(--surface-2); } +.btn-lg { height: 50px; padding: 0 24px; font-size: 15px; border-radius: 12px; } +.btn-sm { height: 32px; padding: 0 12px; font-size: 13px; } +.btn-block { width: 100%; } +.icon-btn { + display: inline-grid; place-items: center; width: 36px; height: 36px; + border-radius: 10px; border: 1px solid transparent; background: transparent; + color: var(--text-muted); cursor: pointer; transition: all .15s; +} +.icon-btn:hover { color: var(--text); background: var(--surface-2); border-color: var(--border); } +.icon-btn.is-active { color: var(--warning); } +.icon-btn.is-active .icon { fill: currentColor; } + +/* ---------- Forms ---------- */ +.field { display: flex; flex-direction: column; gap: 7px; } +.field label, .label { font-size: 13px; font-weight: 600; color: var(--text-muted); } +.field .help { font-size: 12px; color: var(--text-faint); } +.field .help ul { margin: 4px 0 0; padding-left: 18px; } +.errorlist { list-style: none; margin: 0; padding: 0; color: var(--danger); font-size: 13px; } +.input { + width: 100%; height: 44px; padding: 0 14px; + border-radius: var(--radius-sm); border: 1px solid var(--border); + background: var(--surface-2); color: var(--text); + font: 400 15px var(--font); transition: border-color .2s, box-shadow .2s, background .2s; +} +textarea.input { height: auto; padding: 12px 14px; resize: vertical; min-height: 100px; } +select.input { appearance: none; background-image: linear-gradient(45deg, transparent 50%, var(--text-muted) 50%), linear-gradient(135deg, var(--text-muted) 50%, transparent 50%); background-position: calc(100% - 20px) 19px, calc(100% - 15px) 19px; background-size: 5px 5px; background-repeat: no-repeat; padding-right: 36px; } +select.input option { background: var(--surface-solid); color: var(--text); } +.input::placeholder { color: var(--text-faint); } +.input:hover { border-color: var(--border-strong); } +.input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 4px rgba(124, 108, 255, .18); background: var(--surface); } +.input-group { position: relative; display: flex; align-items: center; } +.input-group .input { padding-right: 92px; } +.input-group .input-actions { position: absolute; right: 6px; display: flex; gap: 2px; } +.input-group .input-actions .icon-btn { width: 32px; height: 32px; } +.checkbox { width: 18px; height: 18px; accent-color: var(--primary); } +.check-row { display: flex; align-items: center; gap: 10px; font-size: 14px; color: var(--text-muted); cursor: pointer; user-select: none; } +.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; } +.form-grid .span-2 { grid-column: span 2; } + +/* Strength meter */ +.meter { display: grid; grid-template-columns: repeat(5, 1fr); gap: 5px; margin-top: 4px; } +.meter span { height: 5px; border-radius: 99px; background: var(--surface-2); transition: background .3s; } +.meter[data-score="0"] span:nth-child(-n+1) { background: var(--danger); } +.meter[data-score="1"] span:nth-child(-n+2) { background: #fb923c; } +.meter[data-score="2"] span:nth-child(-n+3) { background: var(--warning); } +.meter[data-score="3"] span:nth-child(-n+4) { background: #a3e635; } +.meter[data-score="4"] span:nth-child(-n+5) { background: var(--success); } +.meter-label { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-faint); } + +/* ---------- Cards & surfaces ---------- */ +.card { + background: var(--surface); border: 1px solid var(--border); + border-radius: var(--radius-lg); padding: 24px; + backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); + box-shadow: var(--shadow); +} +.card-sm { padding: 18px; border-radius: var(--radius); } +.card-title { font-size: 16px; font-weight: 700; } +.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 18px; } +.divider { height: 1px; background: var(--border); margin: 20px 0; border: 0; } + +.badge { + display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 10px; + border-radius: 99px; font-size: 12px; font-weight: 600; + background: var(--surface-2); color: var(--text-muted); border: 1px solid var(--border); +} +.badge-success { color: var(--success); background: rgba(52, 211, 153, .1); border-color: rgba(52, 211, 153, .25); } +.badge-warning { color: var(--warning); background: rgba(251, 191, 36, .1); border-color: rgba(251, 191, 36, .25); } +.badge-danger { color: var(--danger); background: rgba(248, 113, 113, .1); border-color: rgba(248, 113, 113, .25); } +.badge-info { color: var(--info); background: rgba(96, 165, 250, .1); border-color: rgba(96, 165, 250, .25); } +.dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; } + +.chip { + display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; + border-radius: 99px; border: 1px solid var(--border); background: var(--surface); + font-size: 13px; font-weight: 500; color: var(--text-muted); transition: all .15s; white-space: nowrap; +} +.chip:hover { color: var(--text); border-color: var(--border-strong); } +.chip.is-active { color: #fff; background: var(--grad); border-color: transparent; } +.chip .count { font-size: 11px; opacity: .7; } + +/* ---------- App shell ---------- */ +.shell { display: grid; grid-template-columns: var(--sidebar-w) 1fr; min-height: 100vh; background: linear-gradient(to right, rgba(10, 12, 24, .55) calc(var(--sidebar-w) - 1px), var(--border) calc(var(--sidebar-w) - 1px), var(--border) var(--sidebar-w), transparent var(--sidebar-w)); } +:root[data-theme="light"] .shell { background: linear-gradient(to right, rgba(255, 255, 255, .7) calc(var(--sidebar-w) - 1px), var(--border) calc(var(--sidebar-w) - 1px), var(--border) var(--sidebar-w), transparent var(--sidebar-w)); } +.sidebar { + position: sticky; top: 0; height: 100vh; overflow-y: auto; + display: flex; flex-direction: column; gap: 6px; padding: 22px 16px; + border-right: 1px solid var(--border); background: rgba(10, 12, 24, .55); + backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); +} +:root[data-theme="light"] .sidebar { background: rgba(255, 255, 255, .7); } +.brand { display: flex; align-items: center; gap: 11px; padding: 4px 8px 18px; font-weight: 800; font-size: 19px; letter-spacing: -0.03em; } +.brand-mark { width: 36px; height: 36px; border-radius: 11px; background: var(--grad); display: grid; place-items: center; color: #fff; box-shadow: 0 8px 22px -6px rgba(124, 108, 255, .8); } +.brand-mark .icon { width: 20px; height: 20px; } +.nav-section { margin-top: 14px; padding: 0 10px 6px; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--text-faint); } +.nav-link { + display: flex; align-items: center; gap: 12px; height: 40px; padding: 0 12px; + border-radius: 10px; color: var(--text-muted); font-weight: 500; font-size: 14px; transition: all .15s; +} +.nav-link:hover { color: var(--text); background: var(--surface-2); } +.nav-link.is-active { color: var(--text); background: var(--surface-2); box-shadow: inset 3px 0 0 var(--primary); } +.nav-link .count { margin-left: auto; font-size: 12px; color: var(--text-faint); } +.sidebar-footer { margin-top: auto; padding-top: 16px; } +.lock-card { padding: 14px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--surface); font-size: 12px; color: var(--text-muted); } +.lock-card strong { color: var(--text); font-family: var(--mono); font-size: 13px; } + +.main { min-width: 0; display: flex; flex-direction: column; } +.topbar { + position: sticky; top: 0; z-index: 20; + display: flex; align-items: center; gap: 14px; height: 70px; padding: 0 32px; + border-bottom: 1px solid var(--border); background: rgba(7, 8, 15, .6); + backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); +} +:root[data-theme="light"] .topbar { background: rgba(245, 246, 251, .75); } +.search { position: relative; flex: 1; max-width: 520px; } +.search .icon { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); color: var(--text-faint); } +.search .input { padding-left: 42px; padding-right: 54px; height: 42px; border-radius: 12px; } +.search kbd { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); } +kbd { font: 600 11px var(--mono); padding: 3px 7px; border-radius: 6px; border: 1px solid var(--border-strong); color: var(--text-faint); background: var(--surface); } +.avatar { width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center; font-weight: 700; font-size: 13px; color: #fff; background: var(--grad); } +.menu-toggle, .show-mobile { display: none; } +.content { padding: 32px; max-width: 1280px; width: 100%; margin: 0 auto; } +.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 26px; flex-wrap: wrap; } +.page-title { font-size: 28px; font-weight: 800; } +.page-sub { color: var(--text-muted); margin-top: 6px; } + +/* ---------- Stats ---------- */ +.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 26px; } +.stat { position: relative; overflow: hidden; } +.stat-icon { width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; margin-bottom: 14px; } +.stat-value { font-size: 30px; font-weight: 800; letter-spacing: -0.03em; } +.stat-label { font-size: 13px; color: var(--text-muted); } +.tone-primary { color: #a78bfa; background: rgba(139, 92, 246, .14); } +.tone-success { color: var(--success); background: rgba(52, 211, 153, .12); } +.tone-warning { color: var(--warning); background: rgba(251, 191, 36, .12); } +.tone-danger { color: var(--danger); background: rgba(248, 113, 113, .12); } +.tone-info { color: var(--info); background: rgba(96, 165, 250, .12); } + +/* ---------- Vault list ---------- */ +.dash-grid { display: grid; grid-template-columns: 1fr 320px; gap: 22px; align-items: start; } +.filters { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; } +.items { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; } +.item { + position: relative; display: flex; flex-direction: column; gap: 14px; padding: 18px; + border-radius: var(--radius); border: 1px solid var(--border); background: var(--surface); + transition: transform .2s ease, border-color .2s, box-shadow .2s; +} +.item:hover { transform: translateY(-2px); border-color: rgba(124, 108, 255, .45); box-shadow: 0 14px 34px -16px rgba(124, 108, 255, .55); } +.item-head { display: flex; align-items: center; gap: 12px; } +.item-logo { width: 44px; height: 44px; border-radius: 12px; display: grid; place-items: center; font-weight: 800; font-size: 15px; color: #fff; flex: none; } +.item-title { font-weight: 700; font-size: 15px; } +.item-sub { font-size: 13px; color: var(--text-muted); } +.item-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.item-actions { display: flex; gap: 2px; } +.item .stretched::after { content: ""; position: absolute; inset: 0; border-radius: inherit; } +.item .item-actions, .item .fav-form { position: relative; z-index: 1; } +.cat-login { background: linear-gradient(135deg, #6366f1, #8b5cf6); } +.cat-card { background: linear-gradient(135deg, #f59e0b, #ef4444); } +.cat-note { background: linear-gradient(135deg, #10b981, #0ea5e9); } +.cat-api_key { background: linear-gradient(135deg, #ec4899, #8b5cf6); } +.cat-wifi { background: linear-gradient(135deg, #06b6d4, #3b82f6); } +.cat-identity { background: linear-gradient(135deg, #84cc16, #10b981); } + +.empty { text-align: center; padding: 60px 24px; } +.empty-art { width: 76px; height: 76px; margin: 0 auto 18px; border-radius: 22px; display: grid; place-items: center; background: var(--surface-2); color: var(--primary); border: 1px solid var(--border); } +.empty-art .icon { width: 34px; height: 34px; } + +/* Health ring */ +.ring { --p: 0; --c: var(--success); position: relative; width: 140px; height: 140px; border-radius: 50%; display: grid; place-items: center; background: conic-gradient(var(--c) calc(var(--p) * 1%), var(--surface-2) 0); } +.ring::before { content: ""; position: absolute; inset: 11px; border-radius: 50%; background: var(--bg-elev); } +.ring-sm { width: 96px; height: 96px; } +.ring-sm::before { inset: 8px; } +.ring-value { position: relative; text-align: center; } +.ring-value b { display: block; font-size: 32px; font-weight: 800; letter-spacing: -0.03em; } +.ring-sm .ring-value b { font-size: 22px; } +.ring-value span { font-size: 12px; color: var(--text-muted); } + +/* Activity */ +.timeline { display: flex; flex-direction: column; } +.tl-item { display: flex; gap: 14px; padding: 12px 0; border-bottom: 1px dashed var(--border); } +.tl-item:last-child { border-bottom: 0; } +.tl-icon { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; flex: none; } +.tl-icon .icon { width: 16px; height: 16px; } +.tl-title { font-size: 14px; font-weight: 600; } +.tl-meta { font-size: 12px; color: var(--text-faint); } + +.table { width: 100%; border-collapse: collapse; font-size: 14px; } +.table th { text-align: left; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); font-weight: 700; padding: 0 14px 12px; } +.table td { padding: 14px; border-top: 1px solid var(--border); vertical-align: middle; } +.table tr:hover td { background: var(--surface); } +.table-wrap { overflow-x: auto; } + +/* Detail */ +.detail-grid { display: grid; grid-template-columns: 1fr 340px; gap: 22px; align-items: start; } +.kv { display: flex; flex-direction: column; gap: 6px; padding: 16px 0; border-bottom: 1px solid var(--border); } +.kv:last-child { border-bottom: 0; } +.kv-label { font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--text-faint); } +.kv-value { display: flex; align-items: center; gap: 10px; font-size: 15px; min-height: 36px; } +.secret-box { flex: 1; min-width: 0; font-family: var(--mono); font-size: 15px; letter-spacing: .02em; padding: 8px 12px; border-radius: 10px; background: var(--surface-2); border: 1px solid var(--border); overflow-wrap: anywhere; } +.notes-box { white-space: pre-wrap; font-family: var(--mono); font-size: 13.5px; } +.hero-logo { width: 64px; height: 64px; border-radius: 18px; font-size: 22px; } + +/* Generator */ +.gen-output { display: flex; align-items: center; gap: 10px; padding: 22px; border-radius: var(--radius); background: var(--bg-elev); border: 1px solid var(--border-strong); } +.gen-value { flex: 1; min-width: 0; font: 600 22px/1.4 var(--mono); overflow-wrap: anywhere; letter-spacing: .02em; } +.gen-value .d { color: var(--accent); } +.gen-value .s { color: #f472b6; } +.range { width: 100%; accent-color: var(--primary); } +.seg { display: inline-flex; padding: 4px; border-radius: 12px; background: var(--surface-2); border: 1px solid var(--border); } +.seg button { height: 34px; padding: 0 16px; border: 0; border-radius: 9px; background: transparent; color: var(--text-muted); font: 600 13px var(--font); cursor: pointer; } +.seg button.is-active { background: var(--surface-solid); color: var(--text); box-shadow: 0 2px 10px rgba(0, 0, 0, .25); } +.options { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.option { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-radius: 12px; border: 1px solid var(--border); background: var(--surface); cursor: pointer; font-size: 14px; } + +/* Toggle switch */ +.switch { position: relative; width: 40px; height: 22px; flex: none; } +.switch input { opacity: 0; width: 0; height: 0; position: absolute; } +.switch .slider { position: absolute; inset: 0; border-radius: 99px; background: var(--border-strong); transition: .2s; cursor: pointer; } +.switch .slider::before { content: ""; position: absolute; left: 3px; top: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: .2s; } +.switch input:checked + .slider { background: var(--primary); } +.switch input:checked + .slider::before { transform: translateX(18px); } + +/* ---------- Toasts & messages ---------- */ +.toasts { position: fixed; right: 22px; bottom: 22px; z-index: 100; display: flex; flex-direction: column; gap: 10px; max-width: min(380px, calc(100vw - 32px)); } +.toast { + display: flex; align-items: center; gap: 12px; padding: 14px 16px; + border-radius: 14px; border: 1px solid var(--border-strong); + background: var(--surface-solid); box-shadow: var(--shadow); font-size: 14px; + animation: toast-in .35s cubic-bezier(.2, .9, .3, 1.2); +} +.toast.is-leaving { animation: toast-out .25s ease forwards; } +.toast-success .icon { color: var(--success); } +.toast-error .icon { color: var(--danger); } +.toast-warning .icon { color: var(--warning); } +.toast-info .icon { color: var(--info); } +@keyframes toast-in { from { opacity: 0; transform: translateY(14px) scale(.96); } } +@keyframes toast-out { to { opacity: 0; transform: translateX(30px); } } + +/* ---------- Modal ---------- */ +dialog.modal { border: 1px solid var(--border-strong); border-radius: var(--radius-lg); padding: 0; background: var(--surface-solid); color: var(--text); max-width: 440px; width: calc(100% - 32px); box-shadow: 0 30px 80px -20px rgba(0, 0, 0, .7); } +dialog.modal::backdrop { background: rgba(3, 4, 10, .65); backdrop-filter: blur(4px); } +dialog.modal[open] { animation: toast-in .25s ease; } +.modal-body { padding: 26px; } +.modal-icon { width: 48px; height: 48px; border-radius: 14px; display: grid; place-items: center; margin-bottom: 16px; } + +/* ---------- Public / marketing ---------- */ +.public-nav { display: flex; align-items: center; justify-content: space-between; max-width: 1180px; margin: 0 auto; padding: 22px 24px; } +.hero { max-width: 1180px; margin: 0 auto; padding: 60px 24px 40px; display: grid; grid-template-columns: 1.05fr .95fr; gap: 50px; align-items: center; } +.eyebrow { display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 14px; border-radius: 99px; border: 1px solid var(--border-strong); background: var(--surface); font-size: 13px; color: var(--text-muted); margin-bottom: 24px; } +.eyebrow .dot { color: var(--success); box-shadow: 0 0 0 4px rgba(52, 211, 153, .18); } +.hero h1 { font-size: clamp(40px, 6vw, 66px); font-weight: 800; letter-spacing: -0.045em; line-height: 1.02; } +.hero p.lead { font-size: 18px; color: var(--text-muted); margin: 22px 0 32px; max-width: 540px; } +.hero-cta { display: flex; gap: 12px; flex-wrap: wrap; } +.hero-trust { display: flex; gap: 22px; margin-top: 34px; color: var(--text-faint); font-size: 13px; flex-wrap: wrap; } +.hero-trust span { display: inline-flex; align-items: center; gap: 7px; } + +.mock { position: relative; } +.mock-glow { position: absolute; inset: -40px; background: var(--grad); filter: blur(80px); opacity: .28; border-radius: 50%; z-index: -1; } +.mock-window { border-radius: 20px; border: 1px solid var(--border-strong); background: rgba(13, 16, 32, .85); box-shadow: 0 40px 80px -30px rgba(0, 0, 0, .8); overflow: hidden; transform: perspective(1400px) rotateY(-8deg) rotateX(4deg); transition: transform .6s ease; } +:root[data-theme="light"] .mock-window { background: rgba(255, 255, 255, .9); } +.mock:hover .mock-window { transform: perspective(1400px) rotateY(0) rotateX(0); } +.mock-bar { display: flex; gap: 7px; padding: 14px 16px; border-bottom: 1px solid var(--border); } +.mock-bar i { width: 11px; height: 11px; border-radius: 50%; background: var(--border-strong); } +.mock-bar i:nth-child(1) { background: #f87171; } .mock-bar i:nth-child(2) { background: #fbbf24; } .mock-bar i:nth-child(3) { background: #34d399; } +.mock-body { padding: 18px; display: flex; flex-direction: column; gap: 10px; } +.mock-row { display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 12px; background: var(--surface); border: 1px solid var(--border); } +.mock-row .item-logo { width: 38px; height: 38px; font-size: 13px; } +.mock-pw { font-family: var(--mono); color: var(--text-faint); letter-spacing: .15em; font-size: 13px; } + +.section { max-width: 1180px; margin: 0 auto; padding: 70px 24px; } +.section-head { text-align: center; max-width: 640px; margin: 0 auto 44px; } +.section-head h2 { font-size: clamp(28px, 4vw, 40px); font-weight: 800; } +.section-head p { color: var(--text-muted); margin-top: 12px; font-size: 17px; } +.features { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; } +.feature h3 { font-size: 17px; margin: 16px 0 8px; } +.feature p { color: var(--text-muted); font-size: 14.5px; } +.feature .stat-icon { margin: 0; } +.cta-band { text-align: center; padding: 56px 24px; border-radius: 28px; background: linear-gradient(135deg, rgba(139, 92, 246, .22), rgba(34, 211, 238, .12)); border: 1px solid var(--border-strong); } +.cta-band h2 { font-size: clamp(26px, 4vw, 38px); font-weight: 800; } +.cta-band p { color: var(--text-muted); margin: 12px 0 26px; } +.footer { max-width: 1180px; margin: 0 auto; padding: 30px 24px 40px; display: flex; justify-content: space-between; gap: 16px; color: var(--text-faint); font-size: 13px; flex-wrap: wrap; } + +/* Auth */ +.auth-wrap { min-height: calc(100vh - 90px); display: grid; place-items: center; padding: 24px 16px 60px; } +.auth-card { width: 100%; max-width: 440px; } +.auth-card h1 { font-size: 26px; font-weight: 800; } +.auth-card .card { padding: 32px; } +.alert { display: flex; gap: 10px; align-items: flex-start; padding: 12px 14px; border-radius: 12px; font-size: 14px; border: 1px solid; } +.alert-danger { color: var(--danger); background: rgba(248, 113, 113, .08); border-color: rgba(248, 113, 113, .3); } +.alert-warning { color: var(--warning); background: rgba(251, 191, 36, .08); border-color: rgba(251, 191, 36, .3); } +.alert-info { color: var(--info); background: rgba(96, 165, 250, .08); border-color: rgba(96, 165, 250, .3); } + +/* Pagination */ +.pagination { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 20px; } + +/* Reveal-on-load */ +.fade-up { animation: fade-up .5s ease both; } +@keyframes fade-up { from { opacity: 0; transform: translateY(10px); } } +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } .mock-window { transform: none; } } + +/* ---------- Responsive ---------- */ +@media (max-width: 1100px) { + .dash-grid, .detail-grid { grid-template-columns: 1fr; } + .stats { grid-template-columns: repeat(2, 1fr); } +} +@media (max-width: 900px) { + .shell, :root[data-theme="light"] .shell { grid-template-columns: 1fr; background: none; } + .sidebar { position: fixed; z-index: 50; left: 0; width: 280px; transform: translateX(-100%); transition: transform .25s ease; background: var(--surface-solid); } + .sidebar.is-open { transform: none; box-shadow: 20px 0 60px rgba(0, 0, 0, .5); } + .menu-toggle, .show-mobile { display: inline-grid; } + .topbar { padding: 0 16px; } + .content { padding: 22px 16px; } + .hero { grid-template-columns: 1fr; padding-top: 30px; } + .mock-window { transform: none; } + .features { grid-template-columns: 1fr; } + .hide-mobile { display: none !important; } +} +@media (max-width: 560px) { + .stats { grid-template-columns: 1fr 1fr; gap: 10px; } + .stat-value { font-size: 24px; } + .card { padding: 18px; } + .form-grid, .options { grid-template-columns: 1fr; } + .form-grid .span-2 { grid-column: auto; } + .search kbd { display: none; } + .page-title { font-size: 24px; } + .gen-value { font-size: 18px; } +} diff --git a/backend/vault/static/vault/js/app.js b/backend/vault/static/vault/js/app.js new file mode 100644 index 0000000..360e36f --- /dev/null +++ b/backend/vault/static/vault/js/app.js @@ -0,0 +1,265 @@ +/* Vaultify UI behaviour – no dependencies. */ +(() => { + "use strict"; + + const $ = (sel, root = document) => root.querySelector(sel); + const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); + + const csrf = () => { + const m = document.cookie.match(/(?:^|;\s*)csrftoken=([^;]+)/); + return m ? decodeURIComponent(m[1]) : ($("[name=csrfmiddlewaretoken]") || {}).value || ""; + }; + + async function post(url, body) { + const res = await fetch(url, { + method: "POST", + headers: { "X-CSRFToken": csrf(), "Content-Type": "application/json", "X-Requested-With": "fetch" }, + body: body ? JSON.stringify(body) : null, + credentials: "same-origin", + }); + if (res.status === 403 || res.redirected) { window.location.reload(); throw new Error("auth"); } + if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (window.vaultResetLock) window.vaultResetLock(); + return res.json(); + } + + const icon = (name, cls = "icon") => ``; + + // ---------------------------------------------------------------- toasts + const toastRoot = () => $(".toasts") || document.body.appendChild(Object.assign(document.createElement("div"), { className: "toasts" })); + const toastIcons = { success: "check-circle", error: "alert", warning: "alert", info: "info" }; + function toast(message, level = "success") { + const el = document.createElement("div"); + el.className = `toast toast-${level}`; + el.setAttribute("role", "status"); + el.innerHTML = `${icon(toastIcons[level] || "info")}`; + el.querySelector("span").textContent = message; + toastRoot().appendChild(el); + dismissLater(el); + } + function dismissLater(el, ms = 4200) { + setTimeout(() => { el.classList.add("is-leaving"); setTimeout(() => el.remove(), 260); }, ms); + } + $$(".toast").forEach((t) => dismissLater(t, 5000)); + window.vaultToast = toast; + + // ---------------------------------------------------------------- theme + const root = document.documentElement; + $$("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", () => { + const next = root.dataset.theme === "light" ? "dark" : "light"; + root.dataset.theme = next; + try { localStorage.setItem("vaultify-theme", next); } catch (e) { /* ignore */ } + })); + + // ---------------------------------------------------------------- sidebar (mobile) + const sidebar = $(".sidebar"); + $$("[data-sidebar-toggle]").forEach((b) => b.addEventListener("click", (e) => { + e.stopPropagation(); + sidebar && sidebar.classList.toggle("is-open"); + })); + document.addEventListener("click", (e) => { + if (sidebar && sidebar.classList.contains("is-open") && !sidebar.contains(e.target)) sidebar.classList.remove("is-open"); + }); + + // ---------------------------------------------------------------- keyboard shortcuts + document.addEventListener("keydown", (e) => { + const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement.tagName); + if (e.key === "/" && !typing) { + const s = $("#global-search"); + if (s) { e.preventDefault(); s.focus(); s.select(); } + } else if (e.key === "n" && !typing && !e.metaKey && !e.ctrlKey) { + const n = $("[data-shortcut-new]"); + if (n) window.location.href = n.href; + } else if (e.key === "Escape" && typing) { + document.activeElement.blur(); + } + }); + + // ---------------------------------------------------------------- clipboard + async function copyText(text, label = "Copied to clipboard") { + try { + await navigator.clipboard.writeText(text); + } catch (e) { + const ta = Object.assign(document.createElement("textarea"), { value: text }); + ta.style.position = "fixed"; ta.style.opacity = "0"; + document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove(); + } + toast(label); + } + window.vaultCopy = copyText; + + $$("[data-copy-text]").forEach((btn) => btn.addEventListener("click", () => copyText(btn.dataset.copyText, btn.dataset.copyLabel || "Copied"))); + + // ---------------------------------------------------------------- reveal / copy secrets + const HIDE_AFTER = 20000; + $$("[data-copy-secret]").forEach((btn) => btn.addEventListener("click", async (e) => { + e.preventDefault(); + try { + const data = await post(`${btn.dataset.copySecret}?action=copy`); + if (!data.secret) return toast("This item has no secret to copy", "info"); + await copyText(data.secret, "Password copied β€” clipboard clears in 30s"); + setTimeout(() => navigator.clipboard && navigator.clipboard.writeText("").catch(() => {}), 30000); + } catch (err) { if (err.message !== "auth") toast("Could not copy secret", "error"); } + })); + + $$("[data-reveal]").forEach((btn) => { + const target = $(btn.dataset.target); + const notesTarget = btn.dataset.notesTarget ? $(btn.dataset.notesTarget) : null; + let timer; + const hide = () => { + target.textContent = "β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’"; + if (notesTarget) notesTarget.textContent = "Hidden β€” reveal to view"; + btn.innerHTML = icon("eye"); btn.dataset.shown = ""; + btn.setAttribute("aria-label", "Reveal"); + }; + btn.addEventListener("click", async () => { + if (btn.dataset.shown) { clearTimeout(timer); return hide(); } + try { + const data = await post(btn.dataset.reveal); + target.textContent = data.secret || "β€”"; + if (notesTarget) notesTarget.textContent = data.notes || "β€”"; + btn.innerHTML = icon("eye-off"); btn.dataset.shown = "1"; + btn.setAttribute("aria-label", "Hide"); + timer = setTimeout(hide, HIDE_AFTER); + } catch (err) { if (err.message !== "auth") toast("Could not reveal secret", "error"); } + }); + }); + + // ---------------------------------------------------------------- favourites + $$("form.fav-form").forEach((form) => form.addEventListener("submit", async (e) => { + e.preventDefault(); + const btn = $("button", form); + try { + const res = await fetch(form.action, { method: "POST", body: new FormData(form), headers: { "X-Requested-With": "fetch" }, credentials: "same-origin" }); + const data = await res.json(); + btn.classList.toggle("is-active", data.favorite); + toast(data.favorite ? "Pinned to favourites" : "Removed from favourites", "info"); + } catch (err) { form.submit(); } + })); + + // ---------------------------------------------------------------- password visibility in forms + $$("[data-toggle-visibility]").forEach((btn) => btn.addEventListener("click", () => { + const input = $(btn.dataset.toggleVisibility); + const show = input.type === "password"; + input.type = show ? "text" : "password"; + btn.innerHTML = icon(show ? "eye-off" : "eye"); + })); + + // ---------------------------------------------------------------- strength meter + const strengthUrl = document.body.dataset.strengthUrl; + function attachMeter(input) { + const meter = $(`[data-meter-for="${input.id}"]`); + if (!meter || !strengthUrl) return; + const bar = $(".meter", meter), label = $("[data-meter-label]", meter), hint = $("[data-meter-hint]", meter); + let t; + const update = () => { + clearTimeout(t); + t = setTimeout(async () => { + if (!input.value) { bar.dataset.score = ""; label.textContent = "Strength"; hint.textContent = ""; return; } + try { + const s = await post(strengthUrl, { password: input.value }); + bar.dataset.score = s.score; + label.textContent = `${s.label} Β· ${s.entropy} bits`; + hint.textContent = s.feedback[0] || "Looks great."; + } catch (e) { /* ignore */ } + }, 160); + }; + input.addEventListener("input", update); + input.vaultUpdateMeter = update; + update(); + } + $$("input[data-strength]").forEach(attachMeter); + + // ---------------------------------------------------------------- generate into field + const generateUrl = document.body.dataset.generateUrl; + $$("[data-generate-into]").forEach((btn) => btn.addEventListener("click", async () => { + const input = $(btn.dataset.generateInto); + try { + const data = await post(generateUrl, { length: 20 }); + input.value = data.password; + input.type = "text"; + input.dispatchEvent(new Event("input")); + toast("Strong password generated", "success"); + } catch (e) { toast("Could not generate password", "error"); } + })); + + // ---------------------------------------------------------------- confirm modal + const modal = $("#confirm-modal"); + $$("[data-confirm]").forEach((btn) => btn.addEventListener("click", (e) => { + if (!modal || typeof modal.showModal !== "function") { if (!confirm(btn.dataset.confirm)) e.preventDefault(); return; } + e.preventDefault(); + $("[data-modal-text]", modal).textContent = btn.dataset.confirm; + $("[data-modal-title]", modal).textContent = btn.dataset.confirmTitle || "Are you sure?"; + const ok = $("[data-modal-ok]", modal); + ok.onclick = () => { modal.close(); btn.closest("form").submit(); }; + modal.showModal(); + })); + $$("[data-modal-cancel]").forEach((b) => b.addEventListener("click", () => b.closest("dialog").close())); + + // ---------------------------------------------------------------- generator page + const gen = $("#generator"); + if (gen) { + const out = $("#gen-value", gen), meter = $("#gen-meter .meter", gen), label = $("#gen-label", gen); + const lengthInput = $("#gen-length", gen), lengthOut = $("#gen-length-out", gen); + const wordsInput = $("#gen-words", gen), wordsOut = $("#gen-words-out", gen); + let mode = "password"; + const esc = (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c] || c); + const paint = (pw) => pw.split("").map((c) => /\d/.test(c) ? `${c}` : /[^A-Za-z0-9]/.test(c) ? `${esc(c)}` : c).join(""); + const history = []; + const historyEl = $("#gen-history", gen); + const opts = () => ({ + mode, + length: +lengthInput.value, + words: +wordsInput.value, + lowercase: $("#opt-lower", gen).checked, + uppercase: $("#opt-upper", gen).checked, + digits: $("#opt-digits", gen).checked, + symbols: $("#opt-symbols", gen).checked, + exclude_ambiguous: $("#opt-ambiguous", gen).checked, + separator: $("#opt-sep", gen).value, + capitalize: $("#opt-cap", gen).checked, + add_number: $("#opt-num", gen).checked, + }); + const run = async () => { + try { + const data = await post(generateUrl, opts()); + out.dataset.value = data.password; + out.innerHTML = paint(data.password); + meter.dataset.score = data.strength.score; + label.textContent = `${data.strength.label} Β· ${data.strength.entropy} bits of entropy`; + history.unshift(data.password); history.splice(6); + historyEl.innerHTML = history.map((p) => ``).join(""); + } catch (e) { toast("Could not generate password", "error"); } + }; + $$(".seg button", gen).forEach((b) => b.addEventListener("click", () => { + mode = b.dataset.mode; + $$(".seg button", gen).forEach((x) => x.classList.toggle("is-active", x === b)); + $$("[data-mode-panel]", gen).forEach((p) => { p.hidden = p.dataset.modePanel !== mode; }); + run(); + })); + lengthInput.addEventListener("input", () => { lengthOut.textContent = lengthInput.value; run(); }); + wordsInput.addEventListener("input", () => { wordsOut.textContent = wordsInput.value; run(); }); + $$("input[type=checkbox], select", gen).forEach((i) => i.addEventListener("change", run)); + $("#gen-refresh", gen).addEventListener("click", run); + $("#gen-copy", gen).addEventListener("click", () => copyText(out.dataset.value, "Password copied")); + historyEl.addEventListener("click", (e) => { const b = e.target.closest("[data-h]"); if (b) copyText(b.dataset.h, "Copied from history"); }); + run(); + } + + // ---------------------------------------------------------------- auto-lock countdown + const lock = $("[data-lock-seconds]"); + if (lock) { + const total = +lock.dataset.lockSeconds; + let deadline = Date.now() + total * 1000; + const out = $("strong", lock); + // Any successful request to the server refreshes the session, so reset then. + window.vaultResetLock = () => { deadline = Date.now() + total * 1000; }; + const tick = () => { + const left = Math.max(0, Math.round((deadline - Date.now()) / 1000)); + out.textContent = `${String(Math.floor(left / 60)).padStart(2, "0")}:${String(left % 60).padStart(2, "0")}`; + if (left === 0) { const f = $("#logout-form"); if (f) f.submit(); return; } + setTimeout(tick, 1000); + }; + tick(); + } +})(); diff --git a/backend/vault/templates/registration/login.html b/backend/vault/templates/registration/login.html new file mode 100644 index 0000000..f1e54b5 --- /dev/null +++ b/backend/vault/templates/registration/login.html @@ -0,0 +1,29 @@ +{% extends "vault/public_base.html" %} +{% block title %}Sign in{% endblock %} +{% block public %} +
+
+
+
+

Unlock your vault

+

Welcome back. Enter your master password to continue.

+ +
+ {% csrf_token %} + {% if lockout_message %} +
{{ lockout_message }}
+ {% endif %} + {% for error in form.non_field_errors %} +
{{ error }}
+ {% endfor %} + {% include "vault/_field.html" with field=form.username %} + {% include "vault/_field.html" with field=form.password %} + + +
+
+

New to Vaultify? Create an account

+
+
+
+{% endblock %} diff --git a/backend/vault/templates/registration/signup.html b/backend/vault/templates/registration/signup.html new file mode 100644 index 0000000..ad791a0 --- /dev/null +++ b/backend/vault/templates/registration/signup.html @@ -0,0 +1,27 @@ +{% extends "vault/public_base.html" %} +{% block title %}Create account{% endblock %} +{% block public %} +
+
+
+
+

Create your vault

+

Choose a strong master password β€” it's the one key to everything.

+ +
+ {% csrf_token %} + {% for error in form.non_field_errors %} +
{{ error }}
+ {% endfor %} + {% include "vault/_field.html" with field=form.username %} + {% include "vault/_field.html" with field=form.email %} + {% include "vault/_field.html" with field=form.password1 %} + {% include "vault/_field.html" with field=form.password2 %} + +
+
+

Already have a vault? Sign in

+
+
+
+{% endblock %} diff --git a/backend/vault/templates/vault/_field.html b/backend/vault/templates/vault/_field.html new file mode 100644 index 0000000..8150405 --- /dev/null +++ b/backend/vault/templates/vault/_field.html @@ -0,0 +1,26 @@ +
+ {% if field.field.widget.input_type == "checkbox" %} + + {% else %} + + {% if field.field.widget.input_type == "password" %} +
+ {{ field }} +
+ {% if generate %}{% endif %} + +
+
+ {% if "data-strength" in field.field.widget.attrs %} +
+
+
Strength
+
+ {% endif %} + {% else %} + {{ field }} + {% endif %} + {% if field.help_text and show_help %}
{{ field.help_text|safe }}
{% endif %} + {% endif %} + {{ field.errors }} +
diff --git a/backend/vault/templates/vault/_health_group.html b/backend/vault/templates/vault/_health_group.html new file mode 100644 index 0000000..829fbb8 --- /dev/null +++ b/backend/vault/templates/vault/_health_group.html @@ -0,0 +1,20 @@ +{% load vault_tags %} +
+
+ {{ title }} + {{ items|length }} +
+ {% if items %} + + {% else %} +

{{ empty }}

+ {% endif %} +
diff --git a/backend/vault/templates/vault/_icons.html b/backend/vault/templates/vault/_icons.html new file mode 100644 index 0000000..4217da8 --- /dev/null +++ b/backend/vault/templates/vault/_icons.html @@ -0,0 +1,44 @@ + diff --git a/backend/vault/templates/vault/_item_card.html b/backend/vault/templates/vault/_item_card.html new file mode 100644 index 0000000..5f9aed4 --- /dev/null +++ b/backend/vault/templates/vault/_item_card.html @@ -0,0 +1,32 @@ +{% load vault_tags %} +
+
+ +
+ {{ item.title }} +
{{ item.username|default:item.domain|default:item.get_category_display }}
+
+
+ {% csrf_token %} + + +
+
+
+ {{ item.get_category_display }} + {% if item.strength_score is not None %}{{ item.strength_score|strength_label }}{% endif %} + {% for tag in item.tag_list|slice:":2" %}#{{ tag }}{% endfor %} +
+
+ Updated {{ item.updated_at|timesince }} ago +
+ {% if item.encrypted_secret %} + + {% endif %} + {% if item.url %} + + {% endif %} + +
+
+
diff --git a/backend/vault/templates/vault/account.html b/backend/vault/templates/vault/account.html new file mode 100644 index 0000000..507ae80 --- /dev/null +++ b/backend/vault/templates/vault/account.html @@ -0,0 +1,50 @@ +{% extends "vault/app_base.html" %} +{% block title %}Account{% endblock %} +{% block content %} +
+
+

Account

+

Signed in as {{ user.username }}{% if user.email %} Β· {{ user.email }}{% endif %} Β· member since {{ user.date_joined|date:"M Y" }}

+
+
+ +
+
+
+ {% csrf_token %} + +
Change master password

You'll stay signed in on this device.

+ {% include "vault/_field.html" with field=password_form.old_password %} + {% include "vault/_field.html" with field=password_form.new_password1 %} + {% include "vault/_field.html" with field=password_form.new_password2 %} +
+
+ +
+ {% csrf_token %} +
Export vault

Download all {{ item_count }} item{{ item_count|pluralize }} as a decrypted JSON file. Store it somewhere safe.

+
+
+
+
+ + +
+{% endblock %} diff --git a/backend/vault/templates/vault/activity.html b/backend/vault/templates/vault/activity.html new file mode 100644 index 0000000..896c3b6 --- /dev/null +++ b/backend/vault/templates/vault/activity.html @@ -0,0 +1,47 @@ +{% extends "vault/app_base.html" %} +{% load vault_tags %} +{% block title %}Activity log{% endblock %} +{% block content %} +
+
+

Activity log

+

A tamper-evident trail of everything that happens in your vault.

+
+
+ +
+ All + {% for value, label in actions %} + {{ label }} + {% endfor %} +
+ +
+ {% if page.object_list %} +
+ + + + {% for log in page %} + + + + + + + {% endfor %} + +
EventItemIP addressWhen
{{ log.get_action_display }}{% if log.item %}{{ log.item_title }}{% else %}{{ log.item_title|default:"β€”" }}{% endif %}{{ log.ip_address|default:"β€”" }}{{ log.created_at|timesince }} ago
+
+ {% if page.has_other_pages %} + + {% endif %} + {% else %} +

No activity yet

+ {% endif %} +
+{% endblock %} diff --git a/backend/vault/templates/vault/app_base.html b/backend/vault/templates/vault/app_base.html new file mode 100644 index 0000000..2274135 --- /dev/null +++ b/backend/vault/templates/vault/app_base.html @@ -0,0 +1,74 @@ +{% extends "vault/base.html" %} +{% block body %} +{% with url_name=request.resolver_match.url_name %} +
+ + +
+
+ + + +
+ +
+ {% block content %}{% endblock %} +
+
+
+{% endwith %} +{% endblock %} diff --git a/backend/vault/templates/vault/base.html b/backend/vault/templates/vault/base.html new file mode 100644 index 0000000..39ae6d5 --- /dev/null +++ b/backend/vault/templates/vault/base.html @@ -0,0 +1,45 @@ +{% load static %} + + + + + + {% block title %}Vaultify{% endblock %} Β· Vaultify + + + + + + + {% block head %}{% endblock %} + + + {% include "vault/_icons.html" %} + {% block body %}{% endblock %} + +
+ {% for message in messages %} +
+ + {{ message }} +
+ {% endfor %} +
+ + + + + + + + diff --git a/backend/vault/templates/vault/dashboard.html b/backend/vault/templates/vault/dashboard.html new file mode 100644 index 0000000..a5e39f1 --- /dev/null +++ b/backend/vault/templates/vault/dashboard.html @@ -0,0 +1,101 @@ +{% extends "vault/app_base.html" %} +{% load vault_tags %} +{% block title %}Vault{% endblock %} +{% block content %} +
+
+

{% if favorites %}Favourites{% elif active_category %}{% for c in categories %}{% if c.value == active_category %}{{ c.label }}s{% endif %}{% endfor %}{% else %}Good to see you, {{ user.username }}{% endif %}

+

{% if q %}Results for β€œ{{ q }}”{% else %}Your vault holds {{ total }} item{{ total|pluralize }}, all encrypted at rest.{% endif %}

+
+ Add item +
+ +
+
+
+
{{ total }}
Total items
+
+
+
+
{{ favorite_count }}
Favourites
+
+
+
+
{{ report.weak|length|add:0 }} / {{ report.reused|length }}
Weak / reused
+
+ +
+
{{ report.score }}/100
Security score Β· {{ report.grade }}
+
+
+ +
+
+
+ All {{ total }} + {% for c in categories %}{% if c.count %} + + {{ c.label }} {{ c.count }} + + {% endif %}{% endfor %} +
+ {% if tags %} +
+ {% for t in tags %}#{{ t }}{% endfor %} +
+ {% endif %} + + {% if items %} +
+ {% for item in items %}{% include "vault/_item_card.html" %}{% endfor %} +
+ {% else %} +
+
+ {% if q or active_category or favorites or active_tag %} +

Nothing matches

+

Try a different search or clear your filters.

+ Clear filters + {% else %} +

Your vault is empty

+

Add your first login, card or secure note β€” it'll be encrypted instantly.

+ Add your first item + {% endif %} +
+ {% endif %} +
+ + +
+{% endblock %} diff --git a/backend/vault/templates/vault/generator.html b/backend/vault/templates/vault/generator.html new file mode 100644 index 0000000..38095dc --- /dev/null +++ b/backend/vault/templates/vault/generator.html @@ -0,0 +1,78 @@ +{% extends "vault/app_base.html" %} +{% block title %}Password generator{% endblock %} +{% block content %} +
+
+

Password generator

+

Cryptographically secure, generated server-side with Python's secrets module.

+
+
+ +
+
+
+
{{ initial }}
+ + +
+
+
+
Calculating…
+
+ +
+ Type +
+ + +
+
+ +
+
+
20
+ +
+
+ + + + + +
+
+ + +
+ + +
+{% endblock %} diff --git a/backend/vault/templates/vault/health.html b/backend/vault/templates/vault/health.html new file mode 100644 index 0000000..6bad153 --- /dev/null +++ b/backend/vault/templates/vault/health.html @@ -0,0 +1,35 @@ +{% extends "vault/app_base.html" %} +{% load vault_tags %} +{% block title %}Security health{% endblock %} +{% block content %} +
+
+

Security health

+

An at-a-glance audit of the {{ report.total }} secret{{ report.total|pluralize }} in your vault.

+
+
+ +
+
+
{{ report.score }}of 100
+
+ {{ report.grade }} +

+ {% if report.score >= 85 %}Your vault is in great shape.{% elif report.score >= 65 %}Pretty good β€” a few things to tidy up.{% elif report.score >= 40 %}Some secrets need attention.{% else %}Your vault is at risk.{% endif %} +

+

Score is based on weak and reused passwords, with a smaller penalty for passwords older than {{ report.stale_after_days }} days.

+
+
+
{{ report.weak|length }}
Weak
+
{{ report.reused|length }}
Reused
+
{{ report.stale|length }}
Old
+
+
+
+ +
+ {% include "vault/_health_group.html" with title="Weak passwords" items=report.weak tone="danger" icon="alert" empty="No weak passwords. Nice!" %} + {% include "vault/_health_group.html" with title="Reused passwords" items=report.reused tone="warning" icon="repeat" empty="Every password is unique." %} + {% include "vault/_health_group.html" with title="Old passwords" items=report.stale tone="info" icon="clock" empty="All passwords are fresh." %} +
+{% endblock %} diff --git a/backend/vault/templates/vault/item_detail.html b/backend/vault/templates/vault/item_detail.html new file mode 100644 index 0000000..ee16cb3 --- /dev/null +++ b/backend/vault/templates/vault/item_detail.html @@ -0,0 +1,106 @@ +{% extends "vault/app_base.html" %} +{% load vault_tags %} +{% block title %}{{ item.title }}{% endblock %} +{% block content %} + Back to vault + +
+
+ +
+

{{ item.title }}

+
+ {{ item.get_category_display }} + {% if item.favorite %}Favourite{% endif %} + {% for tag in item.tag_list %}#{{ tag }}{% endfor %} +
+
+
+
+ Edit +
+ {% csrf_token %} + +
+
+
+ +{% if reused or item.is_weak %} +
+ + {% if reused %}This password is reused in other items.{% endif %} {% if item.is_weak %}This password is weak.{% endif %} Consider changing it. +
+{% endif %} + +
+
+ {% if item.username %} +
+ Username +
{{ item.username }} +
+
+ {% endif %} + {% if item.encrypted_secret %} +
+ {% if item.category == 'card' %}Card number / PIN{% elif item.category == 'api_key' %}Key{% else %}Password{% endif %} +
+ β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’ + + +
+ {% if item.strength_score is not None %} +
+ {{ item.strength_score|strength_label }} Β· last changed {{ item.secret_changed_at|timesince }} ago + {% endif %} +
+ {% endif %} + {% if item.url %} +
+ Website + +
+ {% endif %} + {% if item.encrypted_notes %} +
+ Notes +
+
Hidden β€” reveal to view
+ {% if not item.encrypted_secret %} + + {% endif %} +
+
+ {% endif %} + {% if not item.username and not item.encrypted_secret and not item.url and not item.encrypted_notes %} +

This item has no details yet. Add some.

+ {% endif %} +
+ + +
+{% endblock %} diff --git a/backend/vault/templates/vault/item_form.html b/backend/vault/templates/vault/item_form.html new file mode 100644 index 0000000..195790b --- /dev/null +++ b/backend/vault/templates/vault/item_form.html @@ -0,0 +1,31 @@ +{% extends "vault/app_base.html" %} +{% block title %}{% if is_new %}New item{% else %}Edit {{ item.title }}{% endif %}{% endblock %} +{% block content %} + Back +
+
+

{% if is_new %}Add to your vault{% else %}Edit β€œ{{ item.title }}”{% endif %}

+

Secrets and notes are encrypted before they touch the database.

+
+
+ +
+ {% csrf_token %} + {% for error in form.non_field_errors %}
{{ error }}
{% endfor %} +
+ {% include "vault/_field.html" with field=form.title %} + {% include "vault/_field.html" with field=form.category %} + {% include "vault/_field.html" with field=form.username %} + {% include "vault/_field.html" with field=form.url %} + {% include "vault/_field.html" with field=form.secret span=True generate=True %} + {% include "vault/_field.html" with field=form.notes span=True %} + {% include "vault/_field.html" with field=form.tags %} +
{% include "vault/_field.html" with field=form.favorite %}
+
+
+
+ Cancel + +
+
+{% endblock %} diff --git a/backend/vault/templates/vault/landing.html b/backend/vault/templates/vault/landing.html new file mode 100644 index 0000000..1392b90 --- /dev/null +++ b/backend/vault/templates/vault/landing.html @@ -0,0 +1,65 @@ +{% extends "vault/public_base.html" %} +{% block title %}Your secrets, encrypted{% endblock %} +{% block public %} +
+
+ AES-encrypted Β· Open source Β· Self-hostable +

Every secret.
One vault.

+

Vaultify keeps your passwords, payment cards, API keys and private notes encrypted at rest β€” with a password generator, security health checks and a full audit trail built in.

+ +
+ Fernet encryption + Auto-lock sessions + REST API + JWT +
+
+ + +
+ +
+
+

Built like a real password manager

+

Everything you expect from a modern vault, in a clean Django codebase you can read in an afternoon.

+
+
+

Encrypted at rest

Secrets and notes are encrypted with Fernet (AES + HMAC) and support zero-downtime key rotation via MultiFernet.

+

Security health

Spot weak, reused and stale passwords instantly β€” reuse is detected with keyed fingerprints, never by decrypting everything.

+

Password generator

Cryptographically secure passwords and memorable passphrases, with live entropy scoring.

+

Audit trail

Every sign-in, reveal, copy, edit and export is logged with IP and timestamp so nothing happens silently.

+

Hardened by default

Brute-force lockout, auto-locking sessions, CSRF protection, strict cookies and owner-scoped data access.

+

REST API

A full Django REST Framework API with JWT auth for building mobile apps, CLIs or browser extensions on top.

+
+
+ +
+
+

Lock it down in under a minute.

+

Create an account and start adding secrets β€” no credit card, no tracking.

+ Get started for free +
+
+ + +{% endblock %} diff --git a/backend/vault/templates/vault/public_base.html b/backend/vault/templates/vault/public_base.html new file mode 100644 index 0000000..8cfa20f --- /dev/null +++ b/backend/vault/templates/vault/public_base.html @@ -0,0 +1,19 @@ +{% extends "vault/base.html" %} +{% block body %} + +{% block public %}{% endblock %} +{% endblock %} diff --git a/backend/vault/templatetags/__init__.py b/backend/vault/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/vault/templatetags/vault_tags.py b/backend/vault/templatetags/vault_tags.py new file mode 100644 index 0000000..86530b2 --- /dev/null +++ b/backend/vault/templatetags/vault_tags.py @@ -0,0 +1,65 @@ +from django import template + +register = template.Library() + +CATEGORY_ICONS = { + "login": "key", + "card": "card", + "note": "note", + "api_key": "code", + "wifi": "wifi", + "identity": "id", +} + +ACTION_ICONS = { + "login": "logout", + "login_failed": "alert", + "logout": "lock", + "create": "plus", + "update": "edit", + "delete": "trash", + "reveal": "eye", + "copy": "copy", + "export": "download", + "password_change": "key", +} + +STRENGTH_TONES = ["danger", "danger", "warning", "success", "success"] +STRENGTH_LABELS = ["Very weak", "Weak", "Fair", "Strong", "Excellent"] + + +@register.filter +def category_icon(category): + return CATEGORY_ICONS.get(category, "key") + + +@register.filter +def action_icon(action): + return ACTION_ICONS.get(action, "activity") + + +@register.filter +def strength_tone(score): + if score is None: + return "" + return STRENGTH_TONES[int(score)] + + +@register.filter +def strength_label(score): + if score is None: + return "" + return STRENGTH_LABELS[int(score)] + + +@register.simple_tag(takes_context=True) +def query_toggle(context, key, value): + """Return the current querystring with ``key`` set to ``value`` (or removed if equal).""" + params = context["request"].GET.copy() + if params.get(key) == str(value): + params.pop(key) + else: + params[key] = value + params.pop("page", None) + encoded = params.urlencode() + return f"?{encoded}" if encoded else "?" diff --git a/backend/vault/tests.py b/backend/vault/tests.py deleted file mode 100644 index b265ba4..0000000 --- a/backend/vault/tests.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.test import TestCase -from .models import VaultItem -from django.contrib.auth.models import User - -class VaultItemModelTest(TestCase): - def test_create_item(self): - user = User.objects.create_user(username='testuser', password='12345') - item = VaultItem.objects.create(user=user, title='Secret', encrypted_data='abc123') - self.assertEqual(str(item), 'Secret') - diff --git a/backend/vault/tests/__init__.py b/backend/vault/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/vault/tests/test_api.py b/backend/vault/tests/test_api.py new file mode 100644 index 0000000..a9d2b74 --- /dev/null +++ b/backend/vault/tests/test_api.py @@ -0,0 +1,80 @@ +from django.contrib.auth import get_user_model +from django.core.cache import cache +from django.urls import reverse +from rest_framework.test import APITestCase + +from vault.models import ActivityLog, VaultItem + + +class VaultAPITests(APITestCase): + def setUp(self): + cache.clear() + User = get_user_model() + self.alice = User.objects.create_user("alice", password="alice-long-password") + self.bob = User.objects.create_user("bob", password="bob-long-password") + + def auth(self, username, password): + res = self.client.post(reverse("api-token"), {"username": username, "password": password}) + self.assertEqual(res.status_code, 200, res.content) + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {res.data['access']}") + + def test_requires_authentication(self): + self.assertEqual(self.client.get("/api/v1/items/").status_code, 401) + + def test_register(self): + res = self.client.post(reverse("api-register"), + {"username": "carol", "password": "Sup3r-Secure-Pass!"}) + self.assertEqual(res.status_code, 201) + self.assertNotIn("password", res.data) + res = self.client.post(reverse("api-register"), {"username": "dave", "password": "123"}) + self.assertEqual(res.status_code, 400) + + def test_create_list_reveal(self): + self.auth("alice", "alice-long-password") + res = self.client.post("/api/v1/items/", { + "title": "GitHub", "username": "alice", "secret": "s3cr3t-Value!", "notes": "2FA on", + "user": self.bob.pk, # must be ignored + }, format="json") + self.assertEqual(res.status_code, 201, res.content) + self.assertNotIn("secret", res.data) + item = VaultItem.objects.get(pk=res.data["id"]) + self.assertEqual(item.user, self.alice) + + listing = self.client.get("/api/v1/items/").data + self.assertEqual(listing["count"], 1) + self.assertNotIn("secret", listing["results"][0]) + + revealed = self.client.get(f"/api/v1/items/{item.pk}/reveal/").data + self.assertEqual(revealed["secret"], "s3cr3t-Value!") + self.assertEqual(revealed["notes"], "2FA on") + self.assertTrue(ActivityLog.objects.filter(user=self.alice, action="reveal").exists()) + + def test_users_cannot_see_each_others_items(self): + item = VaultItem(user=self.bob, title="Bob's bank") + item.secret = "bob-secret" + item.save() + self.auth("alice", "alice-long-password") + self.assertEqual(self.client.get("/api/v1/items/").data["count"], 0) + self.assertEqual(self.client.get(f"/api/v1/items/{item.pk}/").status_code, 404) + self.assertEqual(self.client.get(f"/api/v1/items/{item.pk}/reveal/").status_code, 404) + self.assertEqual(self.client.delete(f"/api/v1/items/{item.pk}/").status_code, 404) + self.assertTrue(VaultItem.objects.filter(pk=item.pk).exists()) + + def test_update_and_search(self): + self.auth("alice", "alice-long-password") + pk = self.client.post("/api/v1/items/", {"title": "Mail", "secret": "a"}, format="json").data["id"] + res = self.client.patch(f"/api/v1/items/{pk}/", {"secret": "N3w-Much-Stronger-Secret!"}, format="json") + self.assertEqual(res.status_code, 200) + self.assertGreaterEqual(res.data["strength"]["score"], 3) + self.assertEqual(self.client.get("/api/v1/items/?q=mai").data["count"], 1) + self.assertEqual(self.client.get("/api/v1/items/?q=zzz").data["count"], 0) + + def test_health_and_tools(self): + self.auth("alice", "alice-long-password") + res = self.client.get("/api/v1/items/health/") + self.assertEqual(res.status_code, 200) + self.assertEqual(res.data["score"], 100) + gen = self.client.post("/api/v1/tools/generate/", {"length": 32}, format="json").data + self.assertEqual(len(gen["password"]), 32) + phrase = self.client.post("/api/v1/tools/generate/", {"mode": "passphrase", "words": 4}, format="json").data + self.assertGreaterEqual(phrase["password"].count("-"), 3) diff --git a/backend/vault/tests/test_crypto_and_passwords.py b/backend/vault/tests/test_crypto_and_passwords.py new file mode 100644 index 0000000..099baf3 --- /dev/null +++ b/backend/vault/tests/test_crypto_and_passwords.py @@ -0,0 +1,56 @@ +import string + +from django.test import SimpleTestCase, override_settings + +from vault import crypto +from vault.passwords import estimate_strength, generate_passphrase, generate_password + + +class CryptoTests(SimpleTestCase): + def test_round_trip(self): + token = crypto.encrypt("hunter2") + self.assertNotIn("hunter2", token) + self.assertEqual(crypto.decrypt(token), "hunter2") + + def test_empty_values(self): + self.assertEqual(crypto.encrypt(""), "") + self.assertEqual(crypto.decrypt(""), "") + + def test_key_rotation(self): + old_key, new_key = crypto.generate_key(), crypto.generate_key() + with override_settings(VAULT_ENCRYPTION_KEYS=[old_key]): + token = crypto.encrypt("rotate me") + with override_settings(VAULT_ENCRYPTION_KEYS=[new_key, old_key]): + self.assertEqual(crypto.decrypt(token), "rotate me") + rotated = crypto.rotate(token) + with override_settings(VAULT_ENCRYPTION_KEYS=[new_key]): + self.assertEqual(crypto.decrypt(rotated), "rotate me") + with self.assertRaises(crypto.DecryptionError): + crypto.decrypt(token) + + +class PasswordTests(SimpleTestCase): + def test_generate_password_respects_length_and_classes(self): + for _ in range(20): + pw = generate_password(24) + self.assertEqual(len(pw), 24) + self.assertTrue(any(c in string.ascii_lowercase for c in pw)) + self.assertTrue(any(c in string.ascii_uppercase for c in pw)) + self.assertTrue(any(c in string.digits for c in pw)) + + def test_generate_password_digits_only(self): + pw = generate_password(12, lowercase=False, uppercase=False, symbols=False) + self.assertTrue(pw.isdigit()) + + def test_exclude_ambiguous(self): + for _ in range(20): + self.assertFalse(set("Il1O0o") & set(generate_password(64, exclude_ambiguous=True))) + + def test_passphrase(self): + phrase = generate_passphrase(words=4, separator=".", add_number=False) + self.assertEqual(len(phrase.split(".")), 4) + + def test_strength(self): + self.assertLessEqual(estimate_strength("password").score, 1) + self.assertEqual(estimate_strength(generate_password(24)).score, 4) + self.assertEqual(estimate_strength("").score, 0) diff --git a/backend/vault/tests/test_models.py b/backend/vault/tests/test_models.py new file mode 100644 index 0000000..fd0d7ce --- /dev/null +++ b/backend/vault/tests/test_models.py @@ -0,0 +1,72 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.utils import timezone + +from vault.models import VaultItem +from vault.security import analyze + + +def make_item(user, title, secret="", **kwargs): + item = VaultItem(user=user, title=title, **kwargs) + item.secret = secret + item.save() + return item + + +class VaultItemModelTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user("alice", password="x-very-long-pass") + + def test_str(self): + self.assertEqual(str(make_item(self.user, "Secret")), "Secret") + + def test_secret_is_encrypted_at_rest(self): + item = make_item(self.user, "Bank", secret="correct horse battery staple") + item.notes = "PIN 1234" + item.save() + raw = VaultItem.objects.values("encrypted_secret", "encrypted_notes").get(pk=item.pk) + self.assertNotIn("correct horse", raw["encrypted_secret"]) + self.assertNotIn("1234", raw["encrypted_notes"]) + fresh = VaultItem.objects.get(pk=item.pk) + self.assertEqual(fresh.secret, "correct horse battery staple") + self.assertEqual(fresh.notes, "PIN 1234") + + def test_strength_and_fingerprint_computed(self): + item = make_item(self.user, "Weak", secret="abc") + self.assertEqual(item.strength_score, 0) + self.assertEqual(len(item.secret_fingerprint), 64) + + def test_secret_changed_at_only_moves_on_change(self): + item = make_item(self.user, "Site", secret="Same-Secret-123!") + item.secret_changed_at = timezone.now() - timedelta(days=10) + item.secret = "Same-Secret-123!" + self.assertGreaterEqual(item.age_days, 10) + item.secret = "Different-Secret-456!" + self.assertEqual(item.age_days, 0) + + def test_tags_and_domain(self): + item = make_item(self.user, "X", tags="work, dev,", url="https://www.github.com/login") + self.assertEqual(item.tag_list, ["work", "dev"]) + self.assertEqual(item.domain, "github.com") + + +class HealthAnalysisTests(TestCase): + def test_detects_weak_reused_and_stale(self): + user = get_user_model().objects.create_user("bob", password="x-very-long-pass") + make_item(user, "A", secret="sunshine2024") + make_item(user, "B", secret="sunshine2024") + old = make_item(user, "C", secret="Xk9$mP2!qL7#vN4@wR8&") + VaultItem.objects.filter(pk=old.pk).update(secret_changed_at=timezone.now() - timedelta(days=400)) + make_item(user, "Note only") # no secret: ignored + + report = analyze(VaultItem.objects.filter(user=user)) + self.assertEqual(report["total"], 3) + self.assertEqual({i.title for i in report["reused"]}, {"A", "B"}) + self.assertEqual({i.title for i in report["stale"]}, {"C"}) + self.assertTrue(report["weak"]) + self.assertLess(report["score"], 50) + + def test_empty_vault_scores_100(self): + self.assertEqual(analyze([])["score"], 100) diff --git a/backend/vault/tests/test_views.py b/backend/vault/tests/test_views.py new file mode 100644 index 0000000..3afda02 --- /dev/null +++ b/backend/vault/tests/test_views.py @@ -0,0 +1,141 @@ +import json + +from django.contrib.auth import get_user_model +from django.core.cache import cache +from django.test import TestCase, override_settings +from django.urls import reverse + +from vault.models import ActivityLog, VaultItem + +PASSWORD = "a-very-long-master-pass" + + +class WebViewTests(TestCase): + def setUp(self): + cache.clear() + User = get_user_model() + self.user = User.objects.create_user("alice", password=PASSWORD) + self.other = User.objects.create_user("mallory", password=PASSWORD) + + def login(self): + self.client.login(username="alice", password=PASSWORD) + + def test_public_pages(self): + for name in ("landing", "login", "signup"): + self.assertEqual(self.client.get(reverse(name)).status_code, 200, name) + + def test_vault_requires_login(self): + res = self.client.get(reverse("vault:dashboard")) + self.assertRedirects(res, f"{reverse('login')}?next={reverse('vault:dashboard')}") + + def test_signup_logs_in(self): + res = self.client.post(reverse("signup"), { + "username": "newbie", "email": "", "password1": "Sup3r-Secure-Pass!", "password2": "Sup3r-Secure-Pass!", + }) + self.assertRedirects(res, reverse("vault:dashboard")) + + def test_login_records_activity_and_lockout(self): + res = self.client.post(reverse("login"), {"username": "alice", "password": PASSWORD}) + self.assertRedirects(res, reverse("vault:dashboard"), fetch_redirect_response=False) + self.assertTrue(ActivityLog.objects.filter(user=self.user, action="login").exists()) + self.client.post(reverse("logout")) + + with override_settings(LOGIN_MAX_ATTEMPTS=3): + for _ in range(3): + self.assertEqual(self.client.post(reverse("login"), {"username": "alice", "password": "nope"}).status_code, 200) + # Even the right password is refused while locked out. + res = self.client.post(reverse("login"), {"username": "alice", "password": PASSWORD}) + self.assertEqual(res.status_code, 429) + self.assertContains(res, "Too many failed attempts", status_code=429) + self.assertEqual(ActivityLog.objects.filter(user=self.user, action="login_failed").count(), 3) + + def test_full_item_lifecycle(self): + self.login() + res = self.client.post(reverse("vault:item_create"), { + "title": "GitHub", "category": "login", "username": "alice", "url": "https://github.com", + "tags": "Work, dev, work", "secret": "Hunter2-Is-Not-Enough!", "notes": "backup codes", + }) + item = VaultItem.objects.get(user=self.user) + self.assertRedirects(res, item.get_absolute_url()) + self.assertEqual(item.tags, "work, dev") + self.assertEqual(item.secret, "Hunter2-Is-Not-Enough!") + + detail = self.client.get(item.get_absolute_url()) + self.assertContains(detail, "GitHub") + self.assertNotContains(detail, "Hunter2-Is-Not-Enough!") # never rendered in HTML + + self.assertContains(self.client.get(reverse("vault:dashboard") + "?q=git"), "GitHub") + + reveal = self.client.post(reverse("vault:item_reveal", args=[item.pk])) + self.assertEqual(json.loads(reveal.content)["secret"], "Hunter2-Is-Not-Enough!") + + self.client.post(reverse("vault:item_update", args=[item.pk]), { + "title": "GitHub Enterprise", "category": "login", "secret": "Hunter2-Is-Not-Enough!", + }) + item.refresh_from_db() + self.assertEqual(item.title, "GitHub Enterprise") + + self.client.post(reverse("vault:item_favorite", args=[item.pk])) + item.refresh_from_db() + self.assertTrue(item.favorite) + + self.client.post(reverse("vault:item_delete", args=[item.pk])) + self.assertFalse(VaultItem.objects.filter(pk=item.pk).exists()) + actions = set(ActivityLog.objects.filter(user=self.user).values_list("action", flat=True)) + self.assertTrue({"create", "reveal", "update", "delete"} <= actions) + + def test_cannot_touch_other_users_items(self): + item = VaultItem(user=self.other, title="Mallory's") + item.secret = "nope" + item.save() + self.login() + self.assertEqual(self.client.get(reverse("vault:item_detail", args=[item.pk])).status_code, 404) + self.assertEqual(self.client.post(reverse("vault:item_reveal", args=[item.pk])).status_code, 404) + self.assertEqual(self.client.post(reverse("vault:item_delete", args=[item.pk])).status_code, 404) + self.assertNotContains(self.client.get(reverse("vault:dashboard")), "Mallory") + + def test_reveal_requires_post(self): + self.login() + item = VaultItem.objects.create(user=self.user, title="X") + self.assertEqual(self.client.get(reverse("vault:item_reveal", args=[item.pk])).status_code, 405) + + def test_tool_pages_render(self): + self.login() + for name in ("vault:generator", "vault:health", "vault:activity", "vault:account"): + self.assertEqual(self.client.get(reverse(name)).status_code, 200, name) + + def test_generate_endpoint(self): + res = self.client.post(reverse("vault:generate_api"), json.dumps({"length": 30}), content_type="application/json") + self.assertEqual(len(json.loads(res.content)["password"]), 30) + + def test_export_requires_password(self): + self.login() + item = VaultItem(user=self.user, title="Export me") + item.secret = "exported-secret" + item.save() + bad = self.client.post(reverse("vault:export"), {"password": "wrong"}) + self.assertRedirects(bad, reverse("vault:account")) + good = self.client.post(reverse("vault:export"), {"password": PASSWORD}) + data = json.loads(good.content) + self.assertEqual(data["items"][0]["secret"], "exported-secret") + + def test_change_password_and_delete_account(self): + self.login() + res = self.client.post(reverse("vault:account"), { + "intent": "password", "old_password": PASSWORD, + "new_password1": "Brand-New-Pass-99!", "new_password2": "Brand-New-Pass-99!", + }) + self.assertRedirects(res, reverse("vault:account")) + self.user.refresh_from_db() + self.assertTrue(self.user.check_password("Brand-New-Pass-99!")) + + res = self.client.post(reverse("vault:account"), {"intent": "delete", "password": "Brand-New-Pass-99!"}) + self.assertRedirects(res, reverse("landing")) + self.assertFalse(get_user_model().objects.filter(username="alice").exists()) + + def test_seed_demo_command(self): + from django.core.management import call_command + + call_command("seed_demo", "--password", "demo-pass-123456", verbosity=0) + demo = get_user_model().objects.get(username="demo") + self.assertGreaterEqual(demo.vault_items.count(), 8) diff --git a/backend/vault/urls.py b/backend/vault/urls.py index de5a755..f627634 100644 --- a/backend/vault/urls.py +++ b/backend/vault/urls.py @@ -1,8 +1,22 @@ from django.urls import path -from .views import VaultItemListCreate, VaultItemDetail + +from . import views + +app_name = "vault" urlpatterns = [ - path('', VaultItemListCreate.as_view(), name='vault-list-create'), - path('/', VaultItemDetail.as_view(), name='vault-detail'), + path("", views.dashboard, name="dashboard"), + path("items/new/", views.item_create, name="item_create"), + path("items//", views.item_detail, name="item_detail"), + path("items//edit/", views.item_update, name="item_update"), + path("items//delete/", views.item_delete, name="item_delete"), + path("items//reveal/", views.item_reveal, name="item_reveal"), + path("items//favorite/", views.item_toggle_favorite, name="item_favorite"), + path("generator/", views.generator, name="generator"), + path("health/", views.health, name="health"), + path("activity/", views.activity, name="activity"), + path("account/", views.account, name="account"), + path("account/export/", views.export_vault, name="export"), + path("tools/generate/", views.generate_api, name="generate_api"), + path("tools/strength/", views.strength_api, name="strength_api"), ] - diff --git a/backend/vault/utils.py b/backend/vault/utils.py deleted file mode 100644 index c14dd7a..0000000 --- a/backend/vault/utils.py +++ /dev/null @@ -1,12 +0,0 @@ -import base64 -from cryptography.fernet import Fernet - -def generate_key(): - return Fernet.generate_key() - -def encrypt_data(data, key): - return Fernet(key).encrypt(data.encode()).decode() - -def decrypt_data(token, key): - return Fernet(key).decrypt(token.encode()).decode() - diff --git a/backend/vault/views.py b/backend/vault/views.py index 1259cfb..b18a16c 100644 --- a/backend/vault/views.py +++ b/backend/vault/views.py @@ -1,18 +1,325 @@ -from rest_framework import generics, permissions -from .models import VaultItem -from .serializers import VaultItemSerializer -from .permissions import IsOwner +"""Server-rendered web interface.""" -class VaultItemListCreate(generics.ListCreateAPIView): - queryset = VaultItem.objects.all() - serializer_class = VaultItemSerializer - permission_classes = [permissions.IsAuthenticated] +import json - def perform_create(self, serializer): - serializer.save(user=self.request.user) +from django.conf import settings +from django.contrib import messages +from django.contrib.auth import login, logout, update_session_auth_hash +from django.contrib.auth import views as auth_views +from django.contrib.auth.decorators import login_required +from django.core.cache import cache +from django.core.paginator import Paginator +from django.db.models import Count, Q +from django.http import HttpResponse, JsonResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.utils import timezone +from django.views.decorators.http import require_POST -class VaultItemDetail(generics.RetrieveUpdateDestroyAPIView): - queryset = VaultItem.objects.all() - serializer_class = VaultItemSerializer - permission_classes = [permissions.IsAuthenticated, IsOwner] +from .forms import ( + DeleteAccountForm, + LoginForm, + SignUpForm, + StyledPasswordChangeForm, + VaultItemForm, +) +from .models import ActivityLog, VaultItem +from .passwords import estimate_strength, generate_passphrase, generate_password +from .security import analyze + +# --- Public pages ------------------------------------------------------------- + +def landing(request): + if request.user.is_authenticated: + return redirect("vault:dashboard") + return render(request, "vault/landing.html") + + +def signup(request): + if request.user.is_authenticated: + return redirect("vault:dashboard") + form = SignUpForm(request.POST or None) + if request.method == "POST" and form.is_valid(): + user = form.save() + login(request, user) + messages.success(request, f"Welcome to Vaultify, {user.username}! Your vault is ready.") + return redirect("vault:dashboard") + return render(request, "registration/signup.html", {"form": form}) + + +class LoginView(auth_views.LoginView): + """Login with simple cache-based brute-force lockout.""" + + form_class = LoginForm + redirect_authenticated_user = True + + def _key(self): + username = (self.request.POST.get("username") or "").lower() + ip = self.request.META.get("REMOTE_ADDR", "") + return f"login-attempts:{username}:{ip}" + + def post(self, request, *args, **kwargs): + self.locked_out = cache.get(self._key(), 0) >= settings.LOGIN_MAX_ATTEMPTS + if self.locked_out: + # Don't even attempt authentication while locked out (no password oracle). + minutes = max(1, settings.LOGIN_LOCKOUT_SECONDS // 60) + form = self.form_class(request=request, initial={"username": request.POST.get("username", "")}) + return self.render_to_response(self.get_context_data( + form=form, + lockout_message=f"Too many failed attempts. Try again in {minutes} minute(s).", + ), status=429) + return super().post(request, *args, **kwargs) + + def form_invalid(self, form): + if self.request.method == "POST": + key = self._key() + cache.set(key, cache.get(key, 0) + 1, settings.LOGIN_LOCKOUT_SECONDS) + return super().form_invalid(form) + + def form_valid(self, form): + cache.delete(self._key()) + return super().form_valid(form) + + +@require_POST +def logout_view(request): + logout(request) + messages.info(request, "Vault locked. See you soon.") + return redirect("landing") + + +# --- Vault -------------------------------------------------------------------- + +def _user_items(request): + return VaultItem.objects.filter(user=request.user) + + +@login_required +def dashboard(request): + items = _user_items(request) + all_items = list(items) + q = request.GET.get("q", "").strip() + category = request.GET.get("category", "") + tag = request.GET.get("tag", "").strip().lower() + favorites = request.GET.get("favorites") == "1" + + filtered = items + if q: + filtered = filtered.filter(Q(title__icontains=q) | Q(username__icontains=q) + | Q(url__icontains=q) | Q(tags__icontains=q)) + if category in VaultItem.Category.values: + filtered = filtered.filter(category=category) + if favorites: + filtered = filtered.filter(favorite=True) + if tag: + filtered = [i for i in filtered if tag in i.tag_list] + + counts = dict(items.values_list("category").annotate(n=Count("id"))) + categories = [ + {"value": value, "label": label, "count": counts.get(value, 0)} + for value, label in VaultItem.Category.choices + ] + tags = sorted({t for i in all_items for t in i.tag_list}) + report = analyze(all_items) + + return render(request, "vault/dashboard.html", { + "items": filtered, + "q": q, + "active_category": category, + "active_tag": tag, + "favorites": favorites, + "categories": categories, + "tags": tags, + "total": len(all_items), + "favorite_count": sum(1 for i in all_items if i.favorite), + "report": report, + "recent_activity": ActivityLog.objects.filter(user=request.user)[:5], + }) + + +@login_required +def item_detail(request, pk): + item = get_object_or_404(_user_items(request), pk=pk) + return render(request, "vault/item_detail.html", { + "item": item, + "history": ActivityLog.objects.filter(user=request.user, item=item)[:10], + "reused": item.secret_fingerprint and _user_items(request).filter( + secret_fingerprint=item.secret_fingerprint).exclude(pk=item.pk).exists(), + }) + + +@login_required +def item_create(request): + initial = {"category": request.GET.get("category", VaultItem.Category.LOGIN)} + form = VaultItemForm(request.POST or None, initial=initial) + if request.method == "POST" and form.is_valid(): + item = form.save(commit=False) + item.user = request.user + item.save() + ActivityLog.record(request, ActivityLog.Action.CREATE, item) + messages.success(request, f"β€œ{item.title}” was added to your vault.") + return redirect(item) + return render(request, "vault/item_form.html", {"form": form, "is_new": True}) + + +@login_required +def item_update(request, pk): + item = get_object_or_404(_user_items(request), pk=pk) + form = VaultItemForm(request.POST or None, instance=item) + if request.method == "POST" and form.is_valid(): + form.save() + ActivityLog.record(request, ActivityLog.Action.UPDATE, item) + messages.success(request, f"β€œ{item.title}” was updated.") + return redirect(item) + return render(request, "vault/item_form.html", {"form": form, "item": item, "is_new": False}) + + +@login_required +@require_POST +def item_delete(request, pk): + item = get_object_or_404(_user_items(request), pk=pk) + ActivityLog.record(request, ActivityLog.Action.DELETE, item) + title = item.title + item.delete() + messages.success(request, f"β€œ{title}” was deleted.") + return redirect("vault:dashboard") + + +@login_required +@require_POST +def item_reveal(request, pk): + """Return decrypted fields as JSON. ``?action=copy`` logs a copy event.""" + item = get_object_or_404(_user_items(request), pk=pk) + item.last_accessed_at = timezone.now() + item.save(update_fields=["last_accessed_at"]) + action = ActivityLog.Action.COPY if request.GET.get("action") == "copy" else ActivityLog.Action.REVEAL + ActivityLog.record(request, action, item) + return JsonResponse({"secret": item.secret, "notes": item.notes}) + + +@login_required +@require_POST +def item_toggle_favorite(request, pk): + item = get_object_or_404(_user_items(request), pk=pk) + item.favorite = not item.favorite + item.save(update_fields=["favorite"]) + if request.headers.get("x-requested-with") == "fetch": + return JsonResponse({"favorite": item.favorite}) + return redirect(request.POST.get("next") or "vault:dashboard") + + +# --- Tools -------------------------------------------------------------------- + +@login_required +def generator(request): + return render(request, "vault/generator.html", {"initial": generate_password(20)}) + + +@require_POST +def generate_api(request): + """Lightweight JSON endpoint used by the UI (CSRF-protected).""" + try: + opts = json.loads(request.body or "{}") + except json.JSONDecodeError: + opts = {} + if opts.get("mode") == "passphrase": + value = generate_passphrase( + words=int(opts.get("words", 5)), + separator=str(opts.get("separator", "-"))[:3], + capitalize=bool(opts.get("capitalize", True)), + add_number=bool(opts.get("add_number", True)), + ) + else: + value = generate_password( + length=int(opts.get("length", 20)), + lowercase=bool(opts.get("lowercase", True)), + uppercase=bool(opts.get("uppercase", True)), + digits=bool(opts.get("digits", True)), + symbols=bool(opts.get("symbols", True)), + exclude_ambiguous=bool(opts.get("exclude_ambiguous", False)), + ) + return JsonResponse({"password": value, "strength": estimate_strength(value).as_dict()}) + + +@require_POST +def strength_api(request): + try: + password = json.loads(request.body or "{}").get("password", "") + except (json.JSONDecodeError, AttributeError): + password = "" + return JsonResponse(estimate_strength(str(password)).as_dict()) + + +@login_required +def health(request): + return render(request, "vault/health.html", {"report": analyze(_user_items(request))}) + + +@login_required +def activity(request): + logs = ActivityLog.objects.filter(user=request.user).select_related("item") + action = request.GET.get("action", "") + if action in ActivityLog.Action.values: + logs = logs.filter(action=action) + page = Paginator(logs, 20).get_page(request.GET.get("page")) + return render(request, "vault/activity.html", { + "page": page, "actions": ActivityLog.Action.choices, "active_action": action, + }) + + +# --- Account ------------------------------------------------------------------ + +@login_required +def account(request): + password_form = StyledPasswordChangeForm(request.user) + delete_form = DeleteAccountForm(request.user) + + if request.method == "POST": + intent = request.POST.get("intent") + if intent == "password": + password_form = StyledPasswordChangeForm(request.user, request.POST) + if password_form.is_valid(): + user = password_form.save() + update_session_auth_hash(request, user) + ActivityLog.record(request, ActivityLog.Action.PASSWORD_CHANGE) + messages.success(request, "Your master password was changed.") + return redirect("vault:account") + elif intent == "delete": + delete_form = DeleteAccountForm(request.user, request.POST) + if delete_form.is_valid(): + user = request.user + logout(request) + user.delete() + messages.info(request, "Your account and all vault data were permanently deleted.") + return redirect("landing") + + return render(request, "vault/account.html", { + "password_form": password_form, + "delete_form": delete_form, + "item_count": _user_items(request).count(), + }) + + +@login_required +@require_POST +def export_vault(request): + if not request.user.check_password(request.POST.get("password", "")): + messages.error(request, "Incorrect password β€” export cancelled.") + return redirect("vault:account") + data = { + "exported_at": timezone.now().isoformat(), + "user": request.user.username, + "items": [ + { + "title": i.title, "category": i.category, "username": i.username, "url": i.url, + "secret": i.secret, "notes": i.notes, "tags": i.tag_list, "favorite": i.favorite, + "created_at": i.created_at.isoformat(), "updated_at": i.updated_at.isoformat(), + } + for i in _user_items(request) + ], + } + ActivityLog.record(request, ActivityLog.Action.EXPORT) + response = HttpResponse(json.dumps(data, indent=2), content_type="application/json") + stamp = timezone.now().strftime("%Y%m%d") + response["Content-Disposition"] = f'attachment; filename="vaultify-export-{stamp}.json"' + return response diff --git a/backend/vaultify/settings.py b/backend/vaultify/settings.py index 1ee8872..f6950ad 100644 --- a/backend/vaultify/settings.py +++ b/backend/vaultify/settings.py @@ -1,74 +1,198 @@ +""" +Django settings for the Vaultify project. + +All environment-specific values are read from environment variables (optionally +loaded from a ``.env`` file in the repository root or the ``backend`` folder). +Sensible development defaults are provided so the project runs out of the box +with SQLite. +""" + import os +from datetime import timedelta from pathlib import Path +import dj_database_url +from django.core.exceptions import ImproperlyConfigured +from dotenv import load_dotenv + BASE_DIR = Path(__file__).resolve().parent.parent -SECRET_KEY = 'your-secret-key' +# Load .env from the repo root first, then allow backend/.env to override. +load_dotenv(BASE_DIR.parent / ".env") +load_dotenv(BASE_DIR / ".env", override=True) + + +def env_bool(name: str, default: bool = False) -> bool: + value = os.environ.get(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def env_list(name: str, default: str = "") -> list[str]: + return [item.strip() for item in os.environ.get(name, default).split(",") if item.strip()] + -DEBUG = True +DEBUG = env_bool("DEBUG", default=True) -ALLOWED_HOSTS = [] +SECRET_KEY = os.environ.get("SECRET_KEY", "") +if not SECRET_KEY: + if not DEBUG: + raise ImproperlyConfigured("SECRET_KEY must be set when DEBUG is False.") + SECRET_KEY = "django-insecure-dev-only-key-change-me-0123456789abcdefghijklmnop" + +ALLOWED_HOSTS = env_list("ALLOWED_HOSTS", "localhost,127.0.0.1,[::1]") +CSRF_TRUSTED_ORIGINS = env_list("CSRF_TRUSTED_ORIGINS") INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'rest_framework', - 'vault', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "rest_framework_simplejwt", + "vault", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = 'vaultify.urls' +ROOT_URLCONF = "vaultify.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "vault.context_processors.vault_globals", ], }, }, ] -WSGI_APPLICATION = 'vaultify.wsgi.application' +WSGI_APPLICATION = "vaultify.wsgi.application" +ASGI_APPLICATION = "vaultify.asgi.application" +# Database: DATABASE_URL (e.g. postgres://user:pass@host:5432/db) or SQLite. DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': 'vaultify_db', - 'USER': 'postgres', - 'PASSWORD': 'yourpassword', - 'HOST': 'localhost', - 'PORT': '5432', - } + "default": dj_database_url.config( + default=f"sqlite:///{BASE_DIR / 'db.sqlite3'}", + conn_max_age=600, + ) } AUTH_PASSWORD_VALIDATORS = [ - {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, - {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + "OPTIONS": {"min_length": 10}, + }, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] -LANGUAGE_CODE = 'en-us' -TIME_ZONE = 'UTC' +LANGUAGE_CODE = "en-us" +TIME_ZONE = os.environ.get("TIME_ZONE", "UTC") USE_I18N = True USE_TZ = True -STATIC_URL = 'static/' -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +STORAGES = { + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": { + "BACKEND": ( + "django.contrib.staticfiles.storage.StaticFilesStorage" + if DEBUG + else "whitenoise.storage.CompressedManifestStaticFilesStorage" + ) + }, +} + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +LOGIN_URL = "login" +LOGIN_REDIRECT_URL = "vault:dashboard" +LOGOUT_REDIRECT_URL = "landing" + +# --- Sessions: auto-lock the vault after a period of inactivity --------------- +SESSION_COOKIE_AGE = int(os.environ.get("VAULT_SESSION_TIMEOUT", 60 * 30)) # seconds +SESSION_SAVE_EVERY_REQUEST = True +SESSION_EXPIRE_AT_BROWSER_CLOSE = True +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = "Lax" +CSRF_COOKIE_SAMESITE = "Lax" + +# --- Security hardening (active when DEBUG is off) --------------------------- +X_FRAME_OPTIONS = "DENY" +SECURE_CONTENT_TYPE_NOSNIFF = True +SECURE_REFERRER_POLICY = "same-origin" +if not DEBUG: + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True + SECURE_SSL_REDIRECT = env_bool("SECURE_SSL_REDIRECT", default=True) + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + SECURE_HSTS_SECONDS = int(os.environ.get("SECURE_HSTS_SECONDS", 60 * 60 * 24 * 30)) + SECURE_HSTS_INCLUDE_SUBDOMAINS = True + +# --- Vault encryption -------------------------------------------------------- +# One or more comma-separated Fernet keys. The first key encrypts new data; all +# keys can decrypt, which allows zero-downtime key rotation. When unset, a key +# is derived from SECRET_KEY (fine for development, set it explicitly in prod). +VAULT_ENCRYPTION_KEYS = env_list("VAULT_ENCRYPTION_KEY") + +# Login brute-force protection. +LOGIN_MAX_ATTEMPTS = int(os.environ.get("LOGIN_MAX_ATTEMPTS", 5)) +LOGIN_LOCKOUT_SECONDS = int(os.environ.get("LOGIN_LOCKOUT_SECONDS", 300)) + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "vaultify", + } +} + +# --- Django REST Framework --------------------------------------------------- +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication", + "rest_framework.authentication.SessionAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 25, + "DEFAULT_THROTTLE_CLASSES": [ + "rest_framework.throttling.AnonRateThrottle", + "rest_framework.throttling.UserRateThrottle", + ], + "DEFAULT_THROTTLE_RATES": {"anon": "30/minute", "user": "300/minute"}, +} + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=15), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "AUTH_HEADER_TYPES": ("Bearer",), +} + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": {"console": {"class": "logging.StreamHandler"}}, + "root": {"handlers": ["console"], "level": os.environ.get("LOG_LEVEL", "INFO")}, +} diff --git a/backend/vaultify/urls.py b/backend/vaultify/urls.py index 4c25c36..327d350 100644 --- a/backend/vaultify/urls.py +++ b/backend/vaultify/urls.py @@ -1,8 +1,18 @@ from django.contrib import admin -from django.urls import path, include +from django.urls import include, path + +from vault import views as vault_views + +admin.site.site_header = "Vaultify administration" +admin.site.site_title = "Vaultify admin" urlpatterns = [ - path('admin/', admin.site.urls), - path('api/v1/vault/', include('vault.urls')), + path("", vault_views.landing, name="landing"), + path("login/", vault_views.LoginView.as_view(), name="login"), + path("logout/", vault_views.logout_view, name="logout"), + path("signup/", vault_views.signup, name="signup"), + path("vault/", include("vault.urls")), + path("api/v1/", include("vault.api_urls")), + path("api-auth/", include("rest_framework.urls")), + path("admin/", admin.site.urls), ] - diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png new file mode 100644 index 0000000..78ea152 Binary files /dev/null and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/generator.png b/docs/screenshots/generator.png new file mode 100644 index 0000000..ee2df07 Binary files /dev/null and b/docs/screenshots/generator.png differ diff --git a/docs/screenshots/health.png b/docs/screenshots/health.png new file mode 100644 index 0000000..76e1dfb Binary files /dev/null and b/docs/screenshots/health.png differ diff --git a/docs/screenshots/item.png b/docs/screenshots/item.png new file mode 100644 index 0000000..d29bb30 Binary files /dev/null and b/docs/screenshots/item.png differ diff --git a/docs/screenshots/landing.png b/docs/screenshots/landing.png new file mode 100644 index 0000000..6366871 Binary files /dev/null and b/docs/screenshots/landing.png differ diff --git a/package.json b/package.json deleted file mode 100644 index f388a65..0000000 --- a/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "vaultify-frontend", - "version": "1.0.0", - "description": "Frontend for Vaultify - Secure Vault Management System", - "author": "Sandip", - "license": "MIT", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview", - "lint": "eslint . --ext .js,.jsx", - "format": "prettier --write ." - }, - "dependencies": { - "axios": "^1.6.2", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-router-dom": "^6.16.0" - }, - "devDependencies": { - "@vitejs/plugin-react": "^4.0.0", - "eslint": "^8.55.0", - "eslint-plugin-react": "^7.33.0", - "prettier": "^3.2.4", - "vite": "^5.0.0" - } -}