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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,19 @@ Full reference: [`docs/configuration.md`](docs/configuration.md)
|----------|-------------|
| `GET /health` | Health check |
| `GET /metrics` | Prometheus metrics |
| `POST /pastes` | Create paste |
| `POST /pastes` | Create paste (linked to account if authenticated) |
| `GET /pastes/{id}` | Get paste |
| `GET /pastes/me` | Get authenticated user's pastes |
| `DELETE /pastes/{id}` | Delete paste |
| `POST /auth/register` | Register a new user |
| `POST /auth/login` | Authenticate and get tokens |
| `POST /auth/refresh` | Refresh access token |
| `POST /auth/verify-email` | Verify email address |
| `POST /auth/resend-verification` | Resend verification email |
| `POST /auth/forgot-password` | Request password reset |
| `POST /auth/reset-password` | Reset password |
| `GET /auth/me` | Get current user profile |
| `POST /auth/logout` | Logout and revoke tokens |

Interactive docs at `/docs` when running.

Expand Down
63 changes: 63 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ APP_RATELIMIT_DEFAULT=60/minute
# APP_RATELIMIT_GET_PASTE=10/minute
# APP_RATELIMIT_GET_PASTE_LEGACY=10/minute
# APP_RATELIMIT_CREATE_PASTE=4/minute
# APP_RATELIMIT_CREATE_PASTE_AUTHENTICATED=20/minute
# APP_RATELIMIT_EDIT_PASTE=4/minute
# APP_RATELIMIT_DELETE_PASTE=4/minute

Expand All @@ -116,3 +117,65 @@ APP_RATELIMIT_DEFAULT=60/minute
# Production deployments should set this to a strong random token
# APP_METRICS_TOKEN=your_secure_random_token_here
# Example generation: openssl rand -hex 32

# =============================================================================
# Authentication Configuration
# =============================================================================

# JWT Configuration (REQUIRED for auth)
# IMPORTANT: Change this in production! Must be at least 32 characters.
# Generate with: openssl rand -hex 32
APP_JWT_SECRET_KEY=CHANGE_ME_IN_PRODUCTION_32_CHARS_MIN
# Access token lifetime (default: 15 minutes)
APP_JWT_ACCESS_TOKEN_EXPIRE_MINUTES=15
# Refresh token lifetime (default: 7 days)
APP_JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
# JWT algorithm (default: HS256)
APP_JWT_ALGORITHM=HS256

# SMTP Configuration (REQUIRED for email verification and password reset)
# Leave empty to disable email sending (emails logged instead in dev)
APP_SMTP_HOST=smtp.example.com
APP_SMTP_PORT=587
APP_SMTP_USERNAME=your_smtp_username
APP_SMTP_PASSWORD=your_smtp_password
APP_SMTP_FROM_EMAIL=noreply@devbin.dev
APP_SMTP_USE_TLS=true

# Frontend URL (REQUIRED for email links)
# Used for verification and password reset links in emails
APP_FRONTEND_URL=http://localhost:3000
# Path for email verification links (default: /verify-email)
APP_EMAIL_VERIFY_PATH=/verify-email
# Path for password reset links (default: /reset-password)
APP_PASSWORD_RESET_PATH=/reset-password

# Email Token Expiry
# Verification token lifetime in hours (default: 24)
APP_EMAIL_VERIFICATION_EXPIRE_HOURS=24
# Password reset token lifetime in hours (default: 1)
APP_PASSWORD_RESET_EXPIRE_HOURS=1

# Auth Rate Limits (optional, these are defaults)
# Format: <number>/<second|minute|hour|day>
# APP_RATELIMIT_AUTH_REGISTER=5/hour
# APP_RATELIMIT_AUTH_LOGIN=10/minute
# APP_RATELIMIT_AUTH_REFRESH=20/minute
# APP_RATELIMIT_AUTH_VERIFY_EMAIL=10/minute
# APP_RATELIMIT_AUTH_RESEND_VERIFICATION=3/hour
# APP_RATELIMIT_AUTH_FORGOT_PASSWORD=3/hour
# APP_RATELIMIT_AUTH_RESET_PASSWORD=5/hour
# APP_RATELIMIT_AUTH_ME=60/minute
# APP_RATELIMIT_AUTH_LOGOUT=20/minute

# Password Requirements (optional, these are defaults)
# Minimum password length
# APP_PASSWORD_MIN_LENGTH=8
# Require at least one uppercase letter
# APP_PASSWORD_REQUIRE_UPPERCASE=true
# Require at least one lowercase letter
# APP_PASSWORD_REQUIRE_LOWERCASE=true
# Require at least one digit
# APP_PASSWORD_REQUIRE_DIGIT=true
# Require at least one special character
# APP_PASSWORD_REQUIRE_SPECIAL=false
2 changes: 1 addition & 1 deletion backend/alembic.ini
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ path_separator = os
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = postgresql://postgres:postgres@localhost/devbin
sqlalchemy.url = postgresql://postgres:postgres@localhost:5433/devbin


[post_write_hooks]
Expand Down
192 changes: 192 additions & 0 deletions backend/alembic/versions/20250125_add_auth_tables.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
"""Add authentication tables

Revision ID: add_auth_tables
Revises: 20251219_203917_hash_existing_tokens
Create Date: 2025-01-25

"""

from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "add_auth_tables"
down_revision: str | None = "4e57d32ab2ac"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
# Create users table
op.create_table(
"users",
sa.Column(
"id",
sa.UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column("username", sa.String(50), unique=True, nullable=False),
sa.Column("email", sa.String(255), unique=True, nullable=False),
sa.Column("password_hash", sa.String(255), nullable=False),
sa.Column(
"is_verified", sa.Boolean(), nullable=False, server_default=sa.text("false")
),
sa.Column(
"is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")
),
sa.Column(
"created_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column("last_login_at", sa.TIMESTAMP(timezone=True), nullable=True),
)

# Create indexes for users table
op.create_index("idx_users_email", "users", ["email"])
op.create_index("idx_users_username", "users", ["username"])
op.create_index("idx_users_created_at", "users", ["created_at"])

# Create email_verification_tokens table
op.create_table(
"email_verification_tokens",
sa.Column(
"id",
sa.UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"user_id",
sa.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("token_hash", sa.String(255), nullable=False),
sa.Column("expires_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("used_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)

# Create indexes for email_verification_tokens table
op.create_index(
"idx_email_verification_tokens_user_id",
"email_verification_tokens",
["user_id"],
)
op.create_index(
"idx_email_verification_tokens_expires_at",
"email_verification_tokens",
["expires_at"],
)

# Create password_reset_tokens table
op.create_table(
"password_reset_tokens",
sa.Column(
"id",
sa.UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"user_id",
sa.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("token_hash", sa.String(255), nullable=False),
sa.Column("expires_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("used_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)

# Create indexes for password_reset_tokens table
op.create_index(
"idx_password_reset_tokens_user_id", "password_reset_tokens", ["user_id"]
)
op.create_index(
"idx_password_reset_tokens_expires_at", "password_reset_tokens", ["expires_at"]
)

# Create refresh_tokens table
op.create_table(
"refresh_tokens",
sa.Column(
"id",
sa.UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"user_id",
sa.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("token_hash", sa.String(255), nullable=False),
sa.Column("expires_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("revoked_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column("user_agent", sa.String(512), nullable=True),
sa.Column("ip_address", sa.String(45), nullable=True), # IPv6 max length
)

# Create indexes for refresh_tokens table
op.create_index("idx_refresh_tokens_user_id", "refresh_tokens", ["user_id"])
op.create_index("idx_refresh_tokens_expires_at", "refresh_tokens", ["expires_at"])
op.create_index("idx_refresh_tokens_revoked_at", "refresh_tokens", ["revoked_at"])


def downgrade() -> None:
# Drop refresh_tokens table and indexes
op.drop_index("idx_refresh_tokens_revoked_at", table_name="refresh_tokens")
op.drop_index("idx_refresh_tokens_expires_at", table_name="refresh_tokens")
op.drop_index("idx_refresh_tokens_user_id", table_name="refresh_tokens")
op.drop_table("refresh_tokens")

# Drop password_reset_tokens table and indexes
op.drop_index(
"idx_password_reset_tokens_expires_at", table_name="password_reset_tokens"
)
op.drop_index(
"idx_password_reset_tokens_user_id", table_name="password_reset_tokens"
)
op.drop_table("password_reset_tokens")

# Drop email_verification_tokens table and indexes
op.drop_index(
"idx_email_verification_tokens_expires_at",
table_name="email_verification_tokens",
)
op.drop_index(
"idx_email_verification_tokens_user_id", table_name="email_verification_tokens"
)
op.drop_table("email_verification_tokens")

# Drop users table and indexes
op.drop_index("idx_users_created_at", table_name="users")
op.drop_index("idx_users_username", table_name="users")
op.drop_index("idx_users_email", table_name="users")
op.drop_table("users")
38 changes: 38 additions & 0 deletions backend/alembic/versions/20260404_add_user_id_to_pastes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Add user_id to pastes

Revision ID: add_user_id_to_pastes
Revises: add_auth_tables
Create Date: 2026-04-04

"""

from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "add_user_id_to_pastes"
down_revision: str | None = "add_auth_tables"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.add_column("pastes", sa.Column("user_id", sa.UUID(as_uuid=True), nullable=True))
op.create_foreign_key(
"fk_pastes_user_id",
"pastes",
"users",
["user_id"],
["id"],
ondelete="SET NULL",
)
op.create_index("idx_pastes_user_id", "pastes", ["user_id"])


def downgrade() -> None:
op.drop_index("idx_pastes_user_id", table_name="pastes")
op.drop_constraint("fk_pastes_user_id", "pastes", type_="foreignkey")
op.drop_column("pastes", "user_id")
Loading
Loading