diff --git a/.env.example b/.env.example index 1c3100a..0e2b1f3 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,7 @@ SECRET_KEY=change-this-to-a-secure-random-string-in-production # Admin user (created on first run) ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=changeme + +# TOTP 2FA encryption key (generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())") +# Leave empty to disable TOTP support +TOTP_ENCRYPTION_KEY= diff --git a/backend/.env.example b/backend/.env.example index a9a3b1e..475d140 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -13,3 +13,7 @@ ADMIN_NAME=Admin # CORS (JSON array) CORS_ORIGINS=["http://localhost:5173", "http://localhost:3000"] + +# TOTP 2FA encryption key (generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())") +# Leave empty to disable TOTP support +TOTP_ENCRYPTION_KEY= diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index d2b71bc..e622b5c 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -9,6 +9,7 @@ ) from app.api.deps import AdminUser from app.services.partitions import create_team_partition, drop_team_partition +from app.services.totp import TOTPService router = APIRouter() @@ -93,6 +94,20 @@ async def delete_user(admin: AdminUser, user_id: UUID): return {"message": "User deleted"} +@router.post("/users/{user_id}/reset-totp") +async def reset_user_totp(admin: AdminUser, user_id: UUID): + """Reset TOTP for a user (admin only).""" + user = await User.filter(id=user_id).first() + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + if not user.totp_enabled: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOTP is not enabled for this user") + + await TOTPService.disable(user) + return {"message": "TOTP reset successfully"} + + # ============== Teams ============== async def _team_response(team: Team) -> TeamResponse: diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 8b2cb1e..d3e52da 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1,15 +1,37 @@ from fastapi import APIRouter, HTTPException, status from app.models import User -from app.schemas import Token, LoginRequest, RefreshRequest, UserResponse +from app.models.totp_attempt import TotpAttempt +from app.schemas import ( + Token, LoginRequest, RefreshRequest, UserResponse, + LoginResponse, TOTPVerifyLoginRequest, TOTPSetupResponse, + TOTPVerifySetupRequest, TOTPDisableRequest, +) from app.services.auth import AuthService +from app.services.totp import TOTPService from app.api.deps import CurrentUser router = APIRouter() +_TOTP_MAX_ATTEMPTS = 5 -@router.post("/login", response_model=Token) + +async def _check_totp_attempts(jti: str) -> None: + """Raise 401 if this token has exceeded the attempt limit.""" + count = await TotpAttempt.filter(jti=jti).count() + if count >= _TOTP_MAX_ATTEMPTS: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Too many TOTP attempts, please log in again", + ) + + +async def _record_totp_failure(jti: str) -> None: + await TotpAttempt.create(jti=jti) + + +@router.post("/login", response_model=LoginResponse) async def login(request: LoginRequest): - """Authenticate user and return tokens.""" + """Authenticate user and return tokens, or TOTP challenge if 2FA is enabled.""" user = await User.filter(email=request.email, is_active=True).first() if user is None or not user.verify_password(request.password): @@ -18,14 +40,105 @@ async def login(request: LoginRequest): detail="Invalid email or password", ) - access_token, refresh_token = AuthService.create_tokens(str(user.id)) + if user.totp_enabled: + totp_token = AuthService.create_totp_token(str(user.id)) + return LoginResponse( + totp_required=True, + totp_token=totp_token, + ) - return Token( + access_token, refresh_token = AuthService.create_tokens(str(user.id)) + return LoginResponse( + totp_required=False, access_token=access_token, refresh_token=refresh_token, ) +@router.post("/verify-totp", response_model=Token) +async def verify_totp(request: TOTPVerifyLoginRequest): + """Verify TOTP code and issue real JWT tokens.""" + payload = AuthService.decode_token(request.totp_token) + + if payload is None or payload.type != "totp_required" or not payload.jti: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired TOTP token", + ) + + await _check_totp_attempts(payload.jti) + + user = await User.filter(id=payload.sub, is_active=True).first() + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found or inactive", + ) + + if not await TOTPService.verify_login_code(user, request.code): + await _record_totp_failure(payload.jti) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid TOTP code", + ) + + # Success — clean up attempt records for this token + await TotpAttempt.filter(jti=payload.jti).delete() + + access_token, refresh_token = AuthService.create_tokens(str(user.id)) + return Token(access_token=access_token, refresh_token=refresh_token) + + +@router.post("/totp/setup", response_model=TOTPSetupResponse) +async def totp_setup(user: CurrentUser): + """Begin TOTP setup: generate secret, QR code, and recovery codes.""" + if user.totp_enabled: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="TOTP is already enabled", + ) + + result = await TOTPService.setup_begin(user) + return TOTPSetupResponse(**result) + + +@router.post("/totp/setup/verify") +async def totp_setup_verify(user: CurrentUser, request: TOTPVerifySetupRequest): + """Verify the first TOTP code to complete setup.""" + if user.totp_enabled: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="TOTP is already enabled", + ) + + if not await TOTPService.setup_verify(user, request.code): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid TOTP code", + ) + + return {"message": "TOTP enabled successfully"} + + +@router.post("/totp/disable") +async def totp_disable(user: CurrentUser, request: TOTPDisableRequest): + """Disable TOTP (requires password confirmation).""" + if not user.totp_enabled: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="TOTP is not enabled", + ) + + if not user.verify_password(request.password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid password", + ) + + await TOTPService.disable(user) + return {"message": "TOTP disabled successfully"} + + @router.post("/refresh", response_model=Token) async def refresh(request: RefreshRequest): """Get new access token using refresh token.""" @@ -61,6 +174,4 @@ async def get_current_user_info(user: CurrentUser): @router.post("/logout") async def logout(user: CurrentUser): """Logout user (client should discard tokens).""" - # JWT tokens are stateless, so we just return success - # Client is responsible for discarding the tokens return {"message": "Logged out successfully"} diff --git a/backend/app/config.py b/backend/app/config.py index 974f156..204f7ca 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -17,6 +17,9 @@ class Settings(BaseSettings): admin_password: str = "changeme" admin_name: str = "Admin" + # TOTP + totp_encryption_key: str = "" + # CORS cors_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"] diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 3d30d47..d9f22a6 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,5 +2,7 @@ from app.models.team import Team, TeamMembership, TeamRole from app.models.api_key import ApiKey from app.models.log import Log, LogLevel +from app.models.recovery_code import RecoveryCode +from app.models.totp_attempt import TotpAttempt -__all__ = ["User", "Team", "TeamMembership", "TeamRole", "ApiKey", "Log", "LogLevel"] +__all__ = ["User", "Team", "TeamMembership", "TeamRole", "ApiKey", "Log", "LogLevel", "RecoveryCode", "TotpAttempt"] diff --git a/backend/app/models/recovery_code.py b/backend/app/models/recovery_code.py new file mode 100644 index 0000000..1020bf1 --- /dev/null +++ b/backend/app/models/recovery_code.py @@ -0,0 +1,18 @@ +import bcrypt +from tortoise import fields +from tortoise.models import Model + + +class RecoveryCode(Model): + id = fields.UUIDField(pk=True) + user = fields.ForeignKeyField("models.User", related_name="recovery_codes", on_delete=fields.CASCADE) + code_hash = fields.CharField(max_length=255) + used = fields.BooleanField(default=False) + created_at = fields.DatetimeField(auto_now_add=True) + + class Meta: + table = "recovery_codes" + + def verify_code(self, code: str) -> bool: + """Check a plaintext recovery code against the stored hash.""" + return bcrypt.checkpw(code.encode(), self.code_hash.encode()) diff --git a/backend/app/models/totp_attempt.py b/backend/app/models/totp_attempt.py new file mode 100644 index 0000000..4b6621c --- /dev/null +++ b/backend/app/models/totp_attempt.py @@ -0,0 +1,11 @@ +from tortoise import fields +from tortoise.models import Model + + +class TotpAttempt(Model): + id = fields.UUIDField(pk=True) + jti = fields.CharField(max_length=36, index=True) + created_at = fields.DatetimeField(auto_now_add=True) + + class Meta: + table = "totp_attempts" diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 55314db..4558c35 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -10,11 +10,14 @@ class User(Model): name = fields.CharField(max_length=255) is_admin = fields.BooleanField(default=False) is_active = fields.BooleanField(default=True) + totp_secret_encrypted = fields.TextField(null=True, default=None) + totp_enabled = fields.BooleanField(default=False) created_at = fields.DatetimeField(auto_now_add=True) updated_at = fields.DatetimeField(auto_now=True) # Reverse relations team_memberships: fields.ReverseRelation["TeamMembership"] + recovery_codes: fields.ReverseRelation["RecoveryCode"] class Meta: table = "users" diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 92ac106..52069b5 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1,4 +1,8 @@ -from app.schemas.auth import Token, TokenPayload, LoginRequest, RefreshRequest +from app.schemas.auth import ( + Token, TokenPayload, LoginRequest, RefreshRequest, + LoginResponse, TOTPVerifyLoginRequest, TOTPSetupResponse, + TOTPVerifySetupRequest, TOTPDisableRequest, +) from app.schemas.user import UserCreate, UserUpdate, UserResponse from app.schemas.team import ( TeamCreate, TeamUpdate, TeamResponse, TeamCreateResponse, @@ -10,6 +14,8 @@ __all__ = [ "Token", "TokenPayload", "LoginRequest", "RefreshRequest", + "LoginResponse", "TOTPVerifyLoginRequest", "TOTPSetupResponse", + "TOTPVerifySetupRequest", "TOTPDisableRequest", "UserCreate", "UserUpdate", "UserResponse", "TeamCreate", "TeamUpdate", "TeamResponse", "TeamCreateResponse", "ApiKeyCreate", "ApiKeyResponse", "ApiKeyWithSecret", diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index eda1293..4b30fa7 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -18,4 +18,34 @@ class Token(BaseModel): class TokenPayload(BaseModel): sub: str # user id - type: str # "access" or "refresh" + type: str # "access", "refresh", or "totp_required" + jti: str | None = None # unique token id (used for totp_required tokens) + + +class LoginResponse(BaseModel): + totp_required: bool = False + # Present when totp_required is False (normal login) + access_token: str | None = None + refresh_token: str | None = None + token_type: str = "bearer" + # Present when totp_required is True + totp_token: str | None = None + + +class TOTPVerifyLoginRequest(BaseModel): + totp_token: str + code: str + + +class TOTPSetupResponse(BaseModel): + secret: str + qr_code: str + recovery_codes: list[str] + + +class TOTPVerifySetupRequest(BaseModel): + code: str + + +class TOTPDisableRequest(BaseModel): + password: str diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 9aedfe2..ad4bbdc 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -24,6 +24,7 @@ class UserResponse(BaseModel): name: str is_admin: bool is_active: bool + totp_enabled: bool = False created_at: datetime updated_at: datetime diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index 57f7df7..a36dae4 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -1,3 +1,4 @@ +import uuid from datetime import datetime, timedelta, timezone from jose import jwt, JWTError from app.config import get_settings @@ -27,11 +28,22 @@ def create_refresh_token(user_id: str) -> str: } return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm) + @staticmethod + def create_totp_token(user_id: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(minutes=5) + payload = { + "sub": user_id, + "type": "totp_required", + "jti": str(uuid.uuid4()), + "exp": expire, + } + return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm) + @staticmethod def decode_token(token: str) -> TokenPayload | None: try: payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) - return TokenPayload(sub=payload["sub"], type=payload["type"]) + return TokenPayload(sub=payload["sub"], type=payload["type"], jti=payload.get("jti")) except JWTError: return None diff --git a/backend/app/services/totp.py b/backend/app/services/totp.py new file mode 100644 index 0000000..8f9a8ac --- /dev/null +++ b/backend/app/services/totp.py @@ -0,0 +1,158 @@ +import asyncio +import base64 +import io +import secrets +import string + +import bcrypt +import pyotp +import qrcode +from cryptography.fernet import Fernet + +from app.config import get_settings +from app.models.user import User +from app.models.recovery_code import RecoveryCode + + +class TOTPService: + @staticmethod + def _get_fernet() -> Fernet: + settings = get_settings() + key = settings.totp_encryption_key + if not key: + raise ValueError( + "TOTP_ENCRYPTION_KEY is not set. " + "Generate one with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"" + ) + try: + return Fernet(key.encode()) + except Exception as exc: + raise ValueError( + f"TOTP_ENCRYPTION_KEY is not a valid Fernet key: {exc}. " + "Generate one with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"" + ) from exc + + @classmethod + def encrypt_secret(cls, secret: str) -> str: + return cls._get_fernet().encrypt(secret.encode()).decode() + + @classmethod + def decrypt_secret(cls, encrypted: str) -> str: + return cls._get_fernet().decrypt(encrypted.encode()).decode() + + @staticmethod + def generate_secret() -> str: + return pyotp.random_base32() + + @staticmethod + def verify_code(secret: str, code: str) -> bool: + totp = pyotp.TOTP(secret) + return totp.verify(code, valid_window=1) + + @staticmethod + def get_provisioning_uri(secret: str, email: str) -> str: + totp = pyotp.TOTP(secret) + return totp.provisioning_uri(name=email, issuer_name="SimpleLogs") + + @staticmethod + def generate_qr_base64(uri: str) -> str: + img = qrcode.make(uri) + buffer = io.BytesIO() + img.save(buffer, format="PNG") + b64 = base64.b64encode(buffer.getvalue()).decode() + return f"data:image/png;base64,{b64}" + + @staticmethod + def generate_recovery_codes(count: int = 8) -> list[str]: + alphabet = string.ascii_lowercase + string.digits + return ["".join(secrets.choice(alphabet) for _ in range(8)) for _ in range(count)] + + @staticmethod + def hash_recovery_code(code: str) -> str: + return bcrypt.hashpw(code.encode(), bcrypt.gensalt()).decode() + + @classmethod + async def setup_begin(cls, user: User) -> dict: + """Begin TOTP setup: generate secret, QR code, and recovery codes.""" + secret = cls.generate_secret() + encrypted = cls.encrypt_secret(secret) + + # Store encrypted secret but don't enable yet + user.totp_secret_encrypted = encrypted + await user.save() + + uri = cls.get_provisioning_uri(secret, user.email) + qr_code = cls.generate_qr_base64(uri) + + # Generate and store recovery codes + plain_codes = cls.generate_recovery_codes() + + # Delete any existing recovery codes for this user + await RecoveryCode.filter(user=user).delete() + + # Hash codes in parallel on worker threads to avoid blocking the event loop + hashes = await asyncio.gather( + *(asyncio.to_thread(cls.hash_recovery_code, code) for code in plain_codes) + ) + await RecoveryCode.bulk_create([ + RecoveryCode(user=user, code_hash=h) for h in hashes + ]) + + return { + "secret": secret, + "qr_code": qr_code, + "recovery_codes": plain_codes, + } + + @classmethod + async def setup_verify(cls, user: User, code: str) -> bool: + """Verify the first TOTP code and enable 2FA.""" + if not user.totp_secret_encrypted: + return False + + secret = cls.decrypt_secret(user.totp_secret_encrypted) + if not cls.verify_code(secret, code): + return False + + user.totp_enabled = True + await user.save() + return True + + @classmethod + async def verify_login_code(cls, user: User, code: str) -> bool: + """Verify a TOTP or recovery code during login.""" + if not user.totp_secret_encrypted: + return False + + secret = cls.decrypt_secret(user.totp_secret_encrypted) + + # Try TOTP code first + if cls.verify_code(secret, code): + return True + + # Fallback to recovery codes + normalized = code.strip().lower() + recovery_codes = await RecoveryCode.filter(user=user, used=False).all() + + def _find_matching_code() -> int | None: + for i, rc in enumerate(recovery_codes): + if rc.verify_code(normalized): + return i + return None + + match_idx = await asyncio.to_thread(_find_matching_code) + if match_idx is not None: + rc = recovery_codes[match_idx] + rc.used = True + await rc.save() + return True + + return False + + @classmethod + async def disable(cls, user: User) -> None: + """Disable TOTP and clean up.""" + user.totp_secret_encrypted = None + user.totp_enabled = False + await user.save() + await RecoveryCode.filter(user=user).delete() diff --git a/backend/migrations/005_totp_tables.sql b/backend/migrations/005_totp_tables.sql new file mode 100644 index 0000000..766c944 --- /dev/null +++ b/backend/migrations/005_totp_tables.sql @@ -0,0 +1,34 @@ +-- Migration: Add TOTP two-factor authentication support +-- Adds totp columns to users, creates recovery_codes table + +DO $$ +BEGIN + -- Add TOTP columns to users table + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'totp_secret_encrypted' + ) THEN + ALTER TABLE users ADD COLUMN totp_secret_encrypted TEXT NULL; + ALTER TABLE users ADD COLUMN totp_enabled BOOLEAN NOT NULL DEFAULT FALSE; + RAISE NOTICE 'Added TOTP columns to users table'; + ELSE + RAISE NOTICE 'TOTP columns already exist on users table, skipping'; + END IF; + + -- Create recovery_codes table + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'recovery_codes') THEN + CREATE TABLE recovery_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code_hash VARCHAR(255) NOT NULL, + used BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX idx_recovery_codes_user_id ON recovery_codes(user_id); + + RAISE NOTICE 'recovery_codes table created successfully'; + ELSE + RAISE NOTICE 'recovery_codes table already exists, skipping'; + END IF; +END $$; diff --git a/backend/migrations/006_totp_attempts_table.sql b/backend/migrations/006_totp_attempts_table.sql new file mode 100644 index 0000000..4f7724b --- /dev/null +++ b/backend/migrations/006_totp_attempts_table.sql @@ -0,0 +1,18 @@ +-- Migration: Add TOTP attempt tracking table for brute-force protection + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'totp_attempts') THEN + CREATE TABLE totp_attempts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + jti VARCHAR(36) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX idx_totp_attempts_jti ON totp_attempts(jti); + + RAISE NOTICE 'totp_attempts table created successfully'; + ELSE + RAISE NOTICE 'totp_attempts table already exists, skipping'; + END IF; +END $$; diff --git a/backend/requirements.txt b/backend/requirements.txt index e4dbb09..c6677f8 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,3 +8,5 @@ python-jose[cryptography]>=3.3.0 bcrypt>=4.0.0 python-multipart>=0.0.6 apscheduler>=3.10.4 +pyotp>=2.9.0 +qrcode[pil]>=7.4.0 diff --git a/docker-compose.yml b/docker-compose.yml index 23c2c71..a54b7e9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme} CORS_ORIGINS: '["https://${DOMAIN:-localhost}", "http://${DOMAIN:-localhost}", "http://localhost:5173"]' + TOTP_ENCRYPTION_KEY: ${TOTP_ENCRYPTION_KEY:-} depends_on: db: condition: service_healthy diff --git a/frontend/src/App.vue b/frontend/src/App.vue index bff4cdc..62e7561 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -30,6 +30,9 @@ {{ authStore.user?.email }} + + Settings + Logout diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 41244b3..1f776e0 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -59,10 +59,25 @@ export interface User { name: string is_admin: boolean is_active: boolean + totp_enabled: boolean created_at: string updated_at: string } +export interface LoginResponse { + totp_required: boolean + access_token: string | null + refresh_token: string | null + token_type: string + totp_token: string | null +} + +export interface TOTPSetupResponse { + secret: string + qr_code: string + recovery_codes: string[] +} + export interface ApiKey { id: string team_id: string diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 9f1129b..730bd10 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -25,6 +25,11 @@ const router = createRouter({ name: 'analytics', component: () => import('@/views/AnalyticsView.vue'), }, + { + path: '/settings', + name: 'settings', + component: () => import('@/views/Settings.vue'), + }, { path: '/admin/users', name: 'admin-users', diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 03d8a89..4b56c91 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -1,32 +1,69 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' -import api, { type User } from '@/api/client' +import api, { type User, type LoginResponse } from '@/api/client' export const useAuthStore = defineStore('auth', () => { const user = ref(null) const loading = ref(false) + const totpRequired = ref(false) + const totpToken = ref(null) const isAuthenticated = computed(() => !!user.value) - async function login(email: string, password: string) { + async function login(email: string, password: string): Promise<'success' | 'totp_required' | 'error'> { loading.value = true try { - const response = await api.post('/auth/login', { email, password }) + const response = await api.post('/auth/login', { email, password }) + const data = response.data + + if (data.totp_required) { + totpRequired.value = true + totpToken.value = data.totp_token + return 'totp_required' + } + + localStorage.setItem('access_token', data.access_token!) + localStorage.setItem('refresh_token', data.refresh_token!) + await fetchUser() + return 'success' + } catch (error) { + console.error('Login failed:', error) + return 'error' + } finally { + loading.value = false + } + } + + async function verifyTotp(code: string): Promise { + loading.value = true + try { + const response = await api.post('/auth/verify-totp', { + totp_token: totpToken.value, + code, + }) const { access_token, refresh_token } = response.data localStorage.setItem('access_token', access_token) localStorage.setItem('refresh_token', refresh_token) + totpRequired.value = false + totpToken.value = null + await fetchUser() return true } catch (error) { - console.error('Login failed:', error) + console.error('TOTP verification failed:', error) return false } finally { loading.value = false } } + function clearTotpState() { + totpRequired.value = false + totpToken.value = null + } + async function logout() { try { await api.post('/auth/logout') @@ -36,6 +73,7 @@ export const useAuthStore = defineStore('auth', () => { localStorage.removeItem('access_token') localStorage.removeItem('refresh_token') user.value = null + clearTotpState() } } @@ -58,8 +96,12 @@ export const useAuthStore = defineStore('auth', () => { return { user, loading, + totpRequired, + totpToken, isAuthenticated, login, + verifyTotp, + clearTotpState, logout, fetchUser, initialize, diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index bb128de..ce9d389 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -7,42 +7,83 @@ SimpleLogs - - - - - - - - {{ error }} - - - - - - - - Login - - + + + + + @@ -59,6 +100,7 @@ const authStore = useAuthStore() const email = ref('') const password = ref('') +const totpCode = ref('') const error = ref('') const loading = ref(false) @@ -66,14 +108,37 @@ async function handleLogin() { error.value = '' loading.value = true - const success = await authStore.login(email.value, password.value) + const result = await authStore.login(email.value, password.value) - if (success) { + if (result === 'success') { router.push('/') + } else if (result === 'totp_required') { + // Phase 2 will show automatically via authStore.totpRequired } else { error.value = 'Invalid email or password' } loading.value = false } + +async function handleTotp() { + error.value = '' + loading.value = true + + const success = await authStore.verifyTotp(totpCode.value) + + if (success) { + router.push('/') + } else { + error.value = 'Invalid authentication code' + } + + loading.value = false +} + +function handleBack() { + authStore.clearTotpState() + totpCode.value = '' + error.value = '' +} diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue new file mode 100644 index 0000000..9ac6052 --- /dev/null +++ b/frontend/src/views/Settings.vue @@ -0,0 +1,249 @@ + + + diff --git a/frontend/src/views/admin/Users.vue b/frontend/src/views/admin/Users.vue index 078e768..d1ea58e 100644 --- a/frontend/src/views/admin/Users.vue +++ b/frontend/src/views/admin/Users.vue @@ -25,6 +25,11 @@ {{ item.is_active ? 'Active' : 'Inactive' }} + @@ -32,6 +37,17 @@ mdi-pencil + + mdi-shield-off + Reset 2FA + mdi-delete @@ -78,6 +94,22 @@ + + + + + Reset Two-Factor Authentication + + Are you sure you want to reset 2FA for {{ userToResetTotp?.name }}? + They will be able to log in without a TOTP code. + + + + Cancel + Reset 2FA + + + @@ -89,11 +121,14 @@ const users = ref([]) const loading = ref(false) const saving = ref(false) const deleting = ref(false) +const resettingTotp = ref(false) const dialog = ref(false) const deleteDialog = ref(false) +const resetTotpDialog = ref(false) const editingUser = ref(null) const userToDelete = ref(null) +const userToResetTotp = ref(null) const form = reactive({ name: '', @@ -108,6 +143,7 @@ const headers = [ { title: 'Email', key: 'email' }, { title: 'Role', key: 'is_admin' }, { title: 'Status', key: 'is_active' }, + { title: '2FA', key: 'totp_enabled' }, { title: 'Created', key: 'created_at' }, { title: 'Actions', key: 'actions', sortable: false }, ] @@ -196,6 +232,25 @@ async function deleteUser() { } } +function confirmResetTotp(user: User) { + userToResetTotp.value = user + resetTotpDialog.value = true +} + +async function resetTotp() { + if (!userToResetTotp.value) return + resettingTotp.value = true + try { + await api.post(`/admin/users/${userToResetTotp.value.id}/reset-totp`) + resetTotpDialog.value = false + await fetchUsers() + } catch (error) { + console.error('Failed to reset TOTP:', error) + } finally { + resettingTotp.value = false + } +} + function formatDate(date: string) { return new Date(date).toLocaleDateString() }