diff --git a/apps/admin/src/features/auth/hooks/useLogin.ts b/apps/admin/src/features/auth/hooks/useLogin.ts index 50e56e9..b542473 100644 --- a/apps/admin/src/features/auth/hooks/useLogin.ts +++ b/apps/admin/src/features/auth/hooks/useLogin.ts @@ -3,7 +3,7 @@ import { useNavigate } from "@tanstack/react-router"; import { HTTPError } from "ky"; import { useState } from "react"; import toast from "react-hot-toast"; -import { api } from "../../../utils/api"; +import { api, setTokens } from "../../../utils/api"; export const useLogin = () => { const navigate = useNavigate(); const [username, setUsername] = useState(""); @@ -21,7 +21,7 @@ export const useLogin = () => { return res; }, onSuccess: async (data) => { - localStorage.setItem("token", data.access_token); + setTokens(data.access_token, data.refresh_token); // Check if user is admin try { diff --git a/apps/admin/src/routes/index.tsx b/apps/admin/src/routes/index.tsx index 651de7c..ce29c36 100644 --- a/apps/admin/src/routes/index.tsx +++ b/apps/admin/src/routes/index.tsx @@ -55,6 +55,17 @@ function RouteComponent() { validationErrors, } = useRegister(); + const showRegisterForm = !installationStatus?.is_installed; + + const handleFormSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (showRegisterForm) { + register(); + } else { + login(); + } + }; + if (isInstallationLoading) { return (
@@ -75,8 +86,6 @@ function RouteComponent() { ); } - const showRegisterForm = !installationStatus?.is_installed; - return (
@@ -104,7 +113,10 @@ function RouteComponent() { )}
-
+
{showRegisterForm ? ( <> @@ -162,11 +174,7 @@ function RouteComponent() {

)}
- @@ -187,17 +195,13 @@ function RouteComponent() { onChange={(v) => setPassword(v.target.value)} /> - )} - {" "} -
+ +
); diff --git a/apps/admin/src/utils/api.ts b/apps/admin/src/utils/api.ts index a215915..852e1d4 100644 --- a/apps/admin/src/utils/api.ts +++ b/apps/admin/src/utils/api.ts @@ -1,15 +1,77 @@ import { createApi } from "@opencircle/core"; +import ky from "ky"; const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000/api"; +let isRefreshing = false; +let refreshPromise: Promise | null = null; + +async function refreshAccessToken(): Promise { + const refreshToken = localStorage.getItem("refreshToken"); + if (!refreshToken) { + return null; + } + + try { + const response = await ky + .post(`${API_URL}/auth/refresh`, { + json: { refresh_token: refreshToken }, + }) + .json<{ access_token: string; token_type: string }>(); + + localStorage.setItem("token", response.access_token); + return response.access_token; + } catch { + localStorage.removeItem("token"); + localStorage.removeItem("refreshToken"); + return null; + } +} + +async function getValidAccessToken(): Promise { + if (isRefreshing) { + return refreshPromise; + } + + isRefreshing = true; + refreshPromise = refreshAccessToken(); + const token = await refreshPromise; + isRefreshing = false; + refreshPromise = null; + return token; +} + export const api = createApi(API_URL, { beforeRequest: [ (request) => { - request.headers.set( - "Authorization", - `Bearer ${localStorage.getItem("token")}`, - ); + const token = localStorage.getItem("token"); + if (token) { + request.headers.set("Authorization", `Bearer ${token}`); + } return request; }, ], + afterResponse: [ + async (request, _options, response) => { + if (response.status === 401 && !request.url.includes("/auth/refresh")) { + const newToken = await getValidAccessToken(); + if (newToken) { + request.headers.set("Authorization", `Bearer ${newToken}`); + return ky(request); + } + window.location.href = "/"; + } + return response; + }, + ], }); + +export function setTokens(accessToken: string, refreshToken: string) { + localStorage.setItem("token", accessToken); + localStorage.setItem("refreshToken", refreshToken); +} + +export function clearTokens() { + localStorage.removeItem("token"); + localStorage.removeItem("refreshToken"); +} diff --git a/apps/api/alembic/env.py b/apps/api/alembic/env.py index 559f431..255d128 100644 --- a/apps/api/alembic/env.py +++ b/apps/api/alembic/env.py @@ -5,7 +5,7 @@ from alembic import context from sqlmodel import SQLModel -from src.database.models import Role, PostType, User, Post, Media, Channel, ChannelMember, ChannelType +from src.database.models import Role, PostType, User, Post, Media, Channel, ChannelMember, ChannelType, RefreshToken, RefreshTokenStatus from src.core.settings import settings # this is the Alembic Config object, which provides diff --git a/apps/api/alembic/versions/1fba4de46b5b_adding_refresh_token_model.py b/apps/api/alembic/versions/1fba4de46b5b_adding_refresh_token_model.py new file mode 100644 index 0000000..d5890b5 --- /dev/null +++ b/apps/api/alembic/versions/1fba4de46b5b_adding_refresh_token_model.py @@ -0,0 +1,46 @@ +"""Adding refresh token model + +Revision ID: 1fba4de46b5b +Revises: 21a853626d70 +Create Date: 2025-11-27 09:30:28.046594 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + + +# revision identifiers, used by Alembic. +revision: str = '1fba4de46b5b' +down_revision: Union[str, Sequence[str], None] = '21a853626d70' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('refresh_token', + sa.Column('id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('token_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('user_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('status', sa.Enum('ACTIVE', 'REVOKED', 'EXPIRED', name='refreshtokenstatus'), nullable=False), + sa.Column('expires_at', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_refresh_token_token_hash'), 'refresh_token', ['token_hash'], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_refresh_token_token_hash'), table_name='refresh_token') + op.drop_table('refresh_token') + # ### end Alembic commands ### diff --git a/apps/api/alembic/versions/d307b309adfc_add_device_info_to_refresh_token.py b/apps/api/alembic/versions/d307b309adfc_add_device_info_to_refresh_token.py new file mode 100644 index 0000000..bd439a7 --- /dev/null +++ b/apps/api/alembic/versions/d307b309adfc_add_device_info_to_refresh_token.py @@ -0,0 +1,44 @@ +"""add_device_info_to_refresh_token + +Revision ID: d307b309adfc +Revises: 1fba4de46b5b +Create Date: 2025-11-27 09:39:24.585674 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + + +# revision identifiers, used by Alembic. +revision: str = 'd307b309adfc' +down_revision: Union[str, Sequence[str], None] = '1fba4de46b5b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('refresh_token', sa.Column('device_name', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + op.add_column('refresh_token', sa.Column('device_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + op.add_column('refresh_token', sa.Column('browser', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + op.add_column('refresh_token', sa.Column('os', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + op.add_column('refresh_token', sa.Column('ip_address', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + op.add_column('refresh_token', sa.Column('last_used_at', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('refresh_token', 'last_used_at') + op.drop_column('refresh_token', 'ip_address') + op.drop_column('refresh_token', 'os') + op.drop_column('refresh_token', 'browser') + op.drop_column('refresh_token', 'device_type') + op.drop_column('refresh_token', 'device_name') + # ### end Alembic commands ### diff --git a/apps/api/src/api/account/api.py b/apps/api/src/api/account/api.py index 7e35414..d1b6c63 100644 --- a/apps/api/src/api/account/api.py +++ b/apps/api/src/api/account/api.py @@ -1,3 +1,5 @@ +from typing import List + import jwt from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer @@ -5,9 +7,15 @@ from sqlalchemy.orm import joinedload from sqlmodel import Session, select +from src.api.auth.serializer import ( + RevokeSessionRequest, + RevokeSessionResponse, + SessionResponse, +) from src.core.settings import settings from src.database.engine import get_session as get_db from src.database.models import Role, User, UserSettings, UserSocial +from src.modules.auth.auth_methods import get_user_sessions, revoke_session_by_id from src.modules.user.user_methods import get_user_by_username from .serializer import UserResponse @@ -129,3 +137,42 @@ def get_account( db.refresh(user_with_data) return UserResponse.model_validate(user_with_data) + + +@router.get("/account/sessions", response_model=List[SessionResponse]) +def get_sessions( + current_user: User = Depends(get_current_user), db: Session = Depends(get_db) +): + """Get all active sessions for the current user.""" + sessions = get_user_sessions(db, current_user.id) + return [ + SessionResponse( + id=session.id, + device_name=session.device_name, + device_type=session.device_type, + browser=session.browser, + os=session.os, + ip_address=session.ip_address, + created_at=session.created_at.isoformat() + if hasattr(session.created_at, "isoformat") + else str(session.created_at), + last_used_at=session.last_used_at, + ) + for session in sessions + ] + + +@router.post("/account/sessions/revoke", response_model=RevokeSessionResponse) +def revoke_session( + request: RevokeSessionRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Revoke a specific session by ID.""" + success = revoke_session_by_id(db, request.session_id, current_user.id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Session not found or already revoked", + ) + return RevokeSessionResponse(message="Session revoked successfully") diff --git a/apps/api/src/api/auth/api.py b/apps/api/src/api/auth/api.py index 340975e..532ceea 100644 --- a/apps/api/src/api/auth/api.py +++ b/apps/api/src/api/auth/api.py @@ -1,4 +1,6 @@ -from fastapi import APIRouter, Depends, HTTPException +from datetime import timedelta + +from fastapi import APIRouter, Depends, HTTPException, Request from loguru import logger from sqlalchemy.exc import IntegrityError from sqlmodel import Session @@ -7,10 +9,14 @@ from src.database.engine import get_session as get_db from src.modules.appsettings import appsettings_methods from src.modules.auth.auth_methods import ( + DeviceInfo, create_access_token, + create_refresh_token, login_user, register_user, reset_password, + revoke_refresh_token, + verify_refresh_token, ) from src.modules.auth.email_verification_methods import verify_user_email from src.modules.auth.github_methods import ( @@ -31,6 +37,11 @@ GoogleLoginRequest, GoogleLoginResponse, LoginRequest, + LoginResponse, + LogoutRequest, + LogoutResponse, + RefreshTokenRequest, + RefreshTokenResponse, RegisterRequest, ResetPasswordRequest, ResetPasswordResponse, @@ -38,6 +49,72 @@ VerifyEmailResponse, ) + +def parse_user_agent(user_agent: str) -> dict: + """Parse user agent string to extract browser and OS info.""" + browser = "Unknown Browser" + os = "Unknown OS" + device_type = "desktop" + + ua_lower = user_agent.lower() + + # Detect OS + if "windows" in ua_lower: + os = "Windows" + elif "mac os x" in ua_lower or "macintosh" in ua_lower: + os = "macOS" + elif "linux" in ua_lower: + os = "Linux" + elif "android" in ua_lower: + os = "Android" + device_type = "mobile" + elif "iphone" in ua_lower or "ipad" in ua_lower: + os = "iOS" + device_type = "mobile" if "iphone" in ua_lower else "tablet" + + # Detect Browser + if "edg/" in ua_lower: + browser = "Edge" + elif "chrome" in ua_lower and "safari" in ua_lower: + browser = "Chrome" + elif "firefox" in ua_lower: + browser = "Firefox" + elif "safari" in ua_lower and "chrome" not in ua_lower: + browser = "Safari" + elif "opera" in ua_lower or "opr/" in ua_lower: + browser = "Opera" + + device_name = f"{browser} on {os}" + + return { + "browser": browser, + "os": os, + "device_type": device_type, + "device_name": device_name, + } + + +def get_device_info(request: Request) -> DeviceInfo: + """Extract device info from request headers.""" + user_agent = request.headers.get("user-agent", "") + parsed = parse_user_agent(user_agent) + + # Get client IP (handle proxies) + forwarded_for = request.headers.get("x-forwarded-for") + if forwarded_for: + ip_address = forwarded_for.split(",")[0].strip() + else: + ip_address = request.client.host if request.client else None + + return DeviceInfo( + device_name=parsed["device_name"], + device_type=parsed["device_type"], + browser=parsed["browser"], + os=parsed["os"], + ip_address=ip_address, + ) + + router = APIRouter() @@ -116,8 +193,8 @@ def register_admin(request: RegisterRequest, db: Session = Depends(get_db)): ) -@router.post("/login") -def login(request: LoginRequest, db: Session = Depends(get_db)): +@router.post("/login", response_model=LoginResponse) +def login(request: LoginRequest, http_request: Request, db: Session = Depends(get_db)): from src.modules.user.user_methods import get_user_by_username user = get_user_by_username(db, request.username) @@ -127,12 +204,34 @@ def login(request: LoginRequest, db: Session = Depends(get_db)): detail="Your account has been banned. Please contact support.", ) - result = login_user(db, request.username, request.password) + device_info = get_device_info(http_request) + result = login_user(db, request.username, request.password, device_info) if not result: raise HTTPException(status_code=401, detail="Invalid credentials") return result +@router.post("/refresh", response_model=RefreshTokenResponse) +def refresh_token(request: RefreshTokenRequest, db: Session = Depends(get_db)): + """Exchange a refresh token for a new access token.""" + user = verify_refresh_token(db, request.refresh_token) + if not user: + raise HTTPException(status_code=401, detail="Invalid or expired refresh token") + + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": user.username}, expires_delta=access_token_expires + ) + return RefreshTokenResponse(access_token=access_token, token_type="bearer") + + +@router.post("/logout", response_model=LogoutResponse) +def logout(request: LogoutRequest, db: Session = Depends(get_db)): + """Revoke a refresh token (logout).""" + revoke_refresh_token(db, request.refresh_token) + return LogoutResponse(message="Successfully logged out") + + @router.get("/github/login", response_model=GitHubAuthUrlResponse) async def github_login(): """Get GitHub authorization URL for OAuth login.""" @@ -147,6 +246,7 @@ async def github_login(): @router.post("/github/callback", response_model=GitHubLoginResponse) async def github_callback( request: GitHubLoginRequest, + http_request: Request, db: Session = Depends(get_db), ): """Handle GitHub OAuth callback and complete login.""" @@ -170,15 +270,16 @@ async def github_callback( detail="Your account has been banned. Please contact support.", ) - from datetime import timedelta - + device_info = get_device_info(http_request) access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) access_token = create_access_token( data={"sub": user.username}, expires_delta=access_token_expires ) + refresh_token = create_refresh_token(db, user.id, device_info) return GitHubLoginResponse( access_token=access_token, + refresh_token=refresh_token, token_type="bearer", user_id=user.id, username=user.username, @@ -215,6 +316,7 @@ async def google_login(): @router.post("/google/callback", response_model=GoogleLoginResponse) async def google_callback( request: GoogleLoginRequest, + http_request: Request, db: Session = Depends(get_db), ): """Handle Google OAuth callback and complete login.""" @@ -240,15 +342,16 @@ async def google_callback( detail="Your account has been banned. Please contact support.", ) - from datetime import timedelta - + device_info = get_device_info(http_request) access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) access_token = create_access_token( data={"sub": user.username}, expires_delta=access_token_expires ) + refresh_token = create_refresh_token(db, user.id, device_info) return GoogleLoginResponse( access_token=access_token, + refresh_token=refresh_token, token_type="bearer", user_id=user.id, username=user.username, diff --git a/apps/api/src/api/auth/serializer.py b/apps/api/src/api/auth/serializer.py index 0fca9f3..d46c82c 100644 --- a/apps/api/src/api/auth/serializer.py +++ b/apps/api/src/api/auth/serializer.py @@ -16,6 +16,29 @@ class LoginRequest(BaseModel): password: str +class LoginResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str + + +class RefreshTokenRequest(BaseModel): + refresh_token: str + + +class RefreshTokenResponse(BaseModel): + access_token: str + token_type: str + + +class LogoutRequest(BaseModel): + refresh_token: str + + +class LogoutResponse(BaseModel): + message: str + + class GitHubLoginRequest(BaseModel): code: str @@ -27,6 +50,7 @@ class GitHubAuthUrlResponse(BaseModel): class GitHubLoginResponse(BaseModel): access_token: str + refresh_token: str token_type: str user_id: str username: str @@ -63,6 +87,7 @@ class GoogleAuthUrlResponse(BaseModel): class GoogleLoginResponse(BaseModel): access_token: str + refresh_token: str token_type: str user_id: str username: str @@ -77,3 +102,22 @@ class VerifyEmailRequest(BaseModel): class VerifyEmailResponse(BaseModel): message: str + + +class SessionResponse(BaseModel): + id: str + device_name: Optional[str] = None + device_type: Optional[str] = None + browser: Optional[str] = None + os: Optional[str] = None + ip_address: Optional[str] = None + created_at: str + last_used_at: Optional[str] = None + + +class RevokeSessionRequest(BaseModel): + session_id: str + + +class RevokeSessionResponse(BaseModel): + message: str diff --git a/apps/api/src/core/settings.py b/apps/api/src/core/settings.py index 0a0aae4..e036ab8 100644 --- a/apps/api/src/core/settings.py +++ b/apps/api/src/core/settings.py @@ -4,8 +4,10 @@ class Settings(BaseSettings): DB_URL: str = "" SECRET_KEY: str = "your-secret-key" + REFRESH_TOKEN_SECRET_KEY: str = "your-refresh-secret-key" ALGORITHM: str = "HS256" - ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 + ACCESS_TOKEN_EXPIRE_MINUTES: int = 15 + REFRESH_TOKEN_EXPIRE_DAYS: int = 30 # R2 Cloudflare Settings R2_ENDPOINT_URL: str = "" R2_ACCESS_KEY_ID: str = "" diff --git a/apps/api/src/database/models.py b/apps/api/src/database/models.py index 6c5dfce..5f9cb41 100644 --- a/apps/api/src/database/models.py +++ b/apps/api/src/database/models.py @@ -53,6 +53,12 @@ class EmailVerificationStatus(str, Enum): EXPIRED = "expired" +class RefreshTokenStatus(str, Enum): + ACTIVE = "active" + REVOKED = "revoked" + EXPIRED = "expired" + + class PostType(str, Enum): POST = "post" COMMENT = "comment" @@ -107,6 +113,25 @@ class EmailVerification(BaseModel, table=True): user: Mapped["User"] = Relationship(sa_relationship=relationship("User")) +class RefreshToken(BaseModel, table=True): + __tablename__ = "refresh_token" # type: ignore + + token_hash: str = Field(index=True, unique=True) + user_id: str = Field(foreign_key="user.id") + status: RefreshTokenStatus = Field(default=RefreshTokenStatus.ACTIVE) + expires_at: str # ISO datetime string + # Device/session info + device_name: str | None = Field(default=None) # e.g., "Chrome on macOS" + device_type: str | None = Field(default=None) # e.g., "desktop", "mobile", "tablet" + browser: str | None = Field(default=None) # e.g., "Chrome 120" + os: str | None = Field(default=None) # e.g., "macOS 14.0" + ip_address: str | None = Field(default=None) + last_used_at: str | None = Field(default=None) # ISO datetime string + user: Mapped["User"] = Relationship( + sa_relationship=relationship("User", back_populates="refresh_tokens") + ) + + class User(BaseModel, table=True): __tablename__ = "user" # type: ignore @@ -165,6 +190,9 @@ class User(BaseModel, table=True): email_verifications: Mapped[List["EmailVerification"]] = Relationship( sa_relationship=relationship("EmailVerification", back_populates="user") ) + refresh_tokens: Mapped[List["RefreshToken"]] = Relationship( + sa_relationship=relationship("RefreshToken", back_populates="user") + ) class UserSettings(BaseModel, table=True): diff --git a/apps/api/src/modules/auth/auth_methods.py b/apps/api/src/modules/auth/auth_methods.py index 93eb544..a25217a 100644 --- a/apps/api/src/modules/auth/auth_methods.py +++ b/apps/api/src/modules/auth/auth_methods.py @@ -1,13 +1,21 @@ +import hashlib +import secrets from datetime import datetime, timedelta, timezone from typing import Optional, TypedDict import jwt from passlib.context import CryptContext from sqlalchemy.exc import IntegrityError -from sqlmodel import Session +from sqlmodel import Session, select from src.core.settings import settings -from src.database.models import InviteCodeStatus, User, UserSettings +from src.database.models import ( + InviteCodeStatus, + RefreshToken, + RefreshTokenStatus, + User, + UserSettings, +) from src.modules.auth.email_verification_methods import create_email_verification from src.modules.auth.password_reset_methods import create_password_reset from src.modules.email.email_service import email_service @@ -41,13 +49,145 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): expire = datetime.now(timezone.utc) + expires_delta else: expire = datetime.now(timezone.utc) + timedelta(minutes=15) - to_encode.update({"exp": expire}) + to_encode.update({"exp": expire, "type": "access"}) encoded_jwt = jwt.encode( to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM ) return encoded_jwt +def generate_refresh_token() -> str: + """Generate a secure random refresh token.""" + return secrets.token_urlsafe(64) + + +def hash_token(token: str) -> str: + """Hash a token for storage.""" + return hashlib.sha256(token.encode()).hexdigest() + + +class DeviceInfo(TypedDict, total=False): + device_name: str + device_type: str + browser: str + os: str + ip_address: str + + +def create_refresh_token( + db: Session, user_id: str, device_info: Optional[DeviceInfo] = None +) -> str: + """Create a refresh token and store it in the database.""" + token = generate_refresh_token() + token_hash = hash_token(token) + expires_at = datetime.now(timezone.utc) + timedelta( + days=settings.REFRESH_TOKEN_EXPIRE_DAYS + ) + + refresh_token = RefreshToken( + token_hash=token_hash, + user_id=user_id, + status=RefreshTokenStatus.ACTIVE, + expires_at=expires_at.isoformat(), + device_name=device_info.get("device_name") if device_info else None, + device_type=device_info.get("device_type") if device_info else None, + browser=device_info.get("browser") if device_info else None, + os=device_info.get("os") if device_info else None, + ip_address=device_info.get("ip_address") if device_info else None, + last_used_at=datetime.now(timezone.utc).isoformat(), + ) + db.add(refresh_token) + db.commit() + + return token + + +def verify_refresh_token(db: Session, token: str) -> Optional[User]: + """Verify a refresh token and return the associated user.""" + token_hash = hash_token(token) + statement = select(RefreshToken).where( + RefreshToken.token_hash == token_hash, + RefreshToken.status == RefreshTokenStatus.ACTIVE, + ) + refresh_token = db.exec(statement).first() + + if not refresh_token: + return None + + # Check expiration + expires_at = datetime.fromisoformat(refresh_token.expires_at.replace("Z", "+00:00")) + if datetime.now(timezone.utc) > expires_at: + refresh_token.status = RefreshTokenStatus.EXPIRED + db.commit() + return None + + # Get user + user = db.get(User, refresh_token.user_id) + if not user or not user.is_active: + return None + + # Update last_used_at + refresh_token.last_used_at = datetime.now(timezone.utc).isoformat() + db.commit() + + return user + + +def revoke_refresh_token(db: Session, token: str) -> bool: + """Revoke a refresh token.""" + token_hash = hash_token(token) + statement = select(RefreshToken).where(RefreshToken.token_hash == token_hash) + refresh_token = db.exec(statement).first() + + if not refresh_token: + return False + + refresh_token.status = RefreshTokenStatus.REVOKED + db.commit() + return True + + +def revoke_all_user_refresh_tokens(db: Session, user_id: str) -> int: + """Revoke all refresh tokens for a user.""" + statement = select(RefreshToken).where( + RefreshToken.user_id == user_id, + RefreshToken.status == RefreshTokenStatus.ACTIVE, + ) + tokens = db.exec(statement).all() + count = 0 + for token in tokens: + token.status = RefreshTokenStatus.REVOKED + count += 1 + db.commit() + return count + + +def get_user_sessions(db: Session, user_id: str) -> list[RefreshToken]: + """Get all active sessions for a user.""" + statement = select(RefreshToken).where( + RefreshToken.user_id == user_id, + RefreshToken.status == RefreshTokenStatus.ACTIVE, + ) + return list(db.exec(statement).all()) + + +def revoke_session_by_id(db: Session, session_id: str, user_id: str) -> bool: + """Revoke a specific session by ID (only if it belongs to the user).""" + statement = select(RefreshToken).where( + RefreshToken.id == session_id, + RefreshToken.user_id == user_id, + RefreshToken.status == RefreshTokenStatus.ACTIVE, + ) + refresh_token = db.exec(statement).first() + + if not refresh_token: + return False + + refresh_token.status = RefreshTokenStatus.REVOKED + db.commit() + return True + + def register_user( db: Session, username: str, @@ -148,11 +288,17 @@ def authenticate_user(db: Session, username: str, password: str) -> Optional[Use class Token(TypedDict): access_token: str + refresh_token: str token_type: str -def login_user(db: Session, username: str, password: str) -> Optional[Token]: - """Login a user and return access token.""" +def login_user( + db: Session, + username: str, + password: str, + device_info: Optional[DeviceInfo] = None, +) -> Optional[Token]: + """Login a user and return access and refresh tokens.""" user = authenticate_user(db, username, password) if not user: return None @@ -165,7 +311,12 @@ def login_user(db: Session, username: str, password: str) -> Optional[Token]: access_token = create_access_token( data={"sub": user.username}, expires_delta=access_token_expires ) - return {"access_token": access_token, "token_type": "bearer"} + refresh_token = create_refresh_token(db, user.id, device_info) + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer", + } def reset_password(db: Session, email: str) -> bool: diff --git a/apps/api/tests/test_auth.py b/apps/api/tests/test_auth.py index e3593c1..d3a6687 100644 --- a/apps/api/tests/test_auth.py +++ b/apps/api/tests/test_auth.py @@ -75,11 +75,16 @@ def test_login(monkeypatch): mock_db = MagicMock() mock_user = MagicMock() mock_user.is_active = True - mock_result = {"access_token": "token", "token_type": "bearer"} - + mock_result = { + "access_token": "token", + "refresh_token": "refresh_token", + "token_type": "bearer", + } + # Mock get_user_by_username to return an active user monkeypatch.setattr( - "src.modules.user.user_methods.get_user_by_username", lambda *args, **kwargs: mock_user + "src.modules.user.user_methods.get_user_by_username", + lambda *args, **kwargs: mock_user, ) # Mock login_user to return the token monkeypatch.setattr( @@ -94,6 +99,7 @@ def test_login(monkeypatch): ) assert response.status_code == 200 assert "access_token" in response.json() + assert "refresh_token" in response.json() def test_login_banned_user(monkeypatch): diff --git a/apps/platform/src/features/auth/components/sessionsTab.tsx b/apps/platform/src/features/auth/components/sessionsTab.tsx new file mode 100644 index 0000000..f07ba74 --- /dev/null +++ b/apps/platform/src/features/auth/components/sessionsTab.tsx @@ -0,0 +1,121 @@ +import { Button } from "@opencircle/ui"; +import { + Globe, + Laptop, + Monitor, + Smartphone, + Tablet, + Trash2, +} from "lucide-react"; +import { useSessions } from "../hooks/useSessions"; + +const getDeviceIcon = (deviceType?: string) => { + switch (deviceType) { + case "mobile": + return ; + case "tablet": + return ; + case "desktop": + return ; + default: + return ; + } +}; + +const formatDate = (dateString?: string) => { + if (!dateString) return "Unknown"; + const date = new Date(dateString); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +}; + +export const SessionsTab = () => { + const { sessions, isLoading, isError, revokeSession, isRevoking } = + useSessions(); + + if (isLoading) { + return ( +
+
Loading sessions...
+
+ ); + } + + if (isError) { + return ( +
+
Failed to load sessions
+
+ ); + } + + if (!sessions || sessions.length === 0) { + return ( +
+
No active sessions
+
+ ); + } + + return ( +
+
+

Active Sessions

+

+ These are devices that are currently logged into your account. You can + revoke access to any session you don't recognize. +

+
+ +
+ {sessions.map((session) => ( +
+
+
+ {getDeviceIcon(session.device_type)} +
+
+
+ {session.device_name || "Unknown Device"} +
+
+ {session.ip_address && ( + + + {session.ip_address} + + )} + {session.browser && session.os && ( + + {session.browser} • {session.os} + + )} +
+
+ Last active: {formatDate(session.last_used_at)} +
+
+
+ +
+ ))} +
+
+ ); +}; diff --git a/apps/platform/src/features/auth/hooks/useGitHubAuth.ts b/apps/platform/src/features/auth/hooks/useGitHubAuth.ts index 593d5a4..d5580f9 100644 --- a/apps/platform/src/features/auth/hooks/useGitHubAuth.ts +++ b/apps/platform/src/features/auth/hooks/useGitHubAuth.ts @@ -3,7 +3,7 @@ import { useNavigate } from "@tanstack/react-router"; import { HTTPError } from "ky"; import { useCallback } from "react"; import toast from "react-hot-toast"; -import { api } from "../../../utils/api"; +import { api, setTokens } from "../../../utils/api"; export const useGitHubAuth = () => { const navigate = useNavigate(); @@ -35,7 +35,7 @@ export const useGitHubAuth = () => { return res; }, onSuccess: (data) => { - localStorage.setItem("token", data.access_token); + setTokens(data.access_token, data.refresh_token); toast.success("Successfully logged in with GitHub!"); navigate({ to: "/" }); }, diff --git a/apps/platform/src/features/auth/hooks/useGoogleAuth.ts b/apps/platform/src/features/auth/hooks/useGoogleAuth.ts index acc43d6..4417c4c 100644 --- a/apps/platform/src/features/auth/hooks/useGoogleAuth.ts +++ b/apps/platform/src/features/auth/hooks/useGoogleAuth.ts @@ -3,7 +3,7 @@ import { useNavigate } from "@tanstack/react-router"; import { HTTPError } from "ky"; import { useCallback } from "react"; import toast from "react-hot-toast"; -import { api } from "../../../utils/api"; +import { api, setTokens } from "../../../utils/api"; export const useGoogleAuth = () => { const navigate = useNavigate(); @@ -35,7 +35,7 @@ export const useGoogleAuth = () => { return res; }, onSuccess: (data) => { - localStorage.setItem("token", data.access_token); + setTokens(data.access_token, data.refresh_token); toast.success("Successfully logged in with Google!"); navigate({ to: "/" }); }, diff --git a/apps/platform/src/features/auth/hooks/useLogin.ts b/apps/platform/src/features/auth/hooks/useLogin.ts index 216e3d4..9e20cb3 100644 --- a/apps/platform/src/features/auth/hooks/useLogin.ts +++ b/apps/platform/src/features/auth/hooks/useLogin.ts @@ -4,7 +4,7 @@ import { HTTPError } from "ky"; import { useState } from "react"; import toast from "react-hot-toast"; import { z } from "zod"; -import { api } from "../../../utils/api"; +import { api, setTokens } from "../../../utils/api"; export const loginSchema = z.object({ username: z @@ -51,7 +51,7 @@ export const useLogin = () => { return res; }, onSuccess: (data) => { - localStorage.setItem("token", data.access_token); + setTokens(data.access_token, data.refresh_token); navigate({ to: "/" }); }, onError: async (error) => { diff --git a/apps/platform/src/features/auth/hooks/useSessions.ts b/apps/platform/src/features/auth/hooks/useSessions.ts new file mode 100644 index 0000000..f1b2baf --- /dev/null +++ b/apps/platform/src/features/auth/hooks/useSessions.ts @@ -0,0 +1,53 @@ +import type { Session } from "@opencircle/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import toast from "react-hot-toast"; +import { api, clearTokens } from "../../../utils/api"; + +export const useSessions = () => { + const navigate = useNavigate(); + + const { + data: sessions, + isLoading, + isError, + error, + refetch, + } = useQuery({ + queryKey: ["sessions"], + queryFn: () => api.account.getSessions(), + }); + + const revokeMutation = useMutation({ + mutationFn: (sessionId: string) => + api.account.revokeSession({ session_id: sessionId }), + onSuccess: async () => { + const result = await refetch(); + const remainingSessions = result.data || []; + + if (remainingSessions.length === 0) { + toast.success("All sessions revoked. Logging out..."); + clearTokens(); + navigate({ to: "/login" }); + } else { + toast.success("Session revoked successfully"); + } + }, + onError: () => { + toast.error("Failed to revoke session"); + }, + }); + + const revokeSession = (sessionId: string) => { + revokeMutation.mutate(sessionId); + }; + + return { + sessions, + isLoading, + isError, + error, + revokeSession, + isRevoking: revokeMutation.isPending, + }; +}; diff --git a/apps/platform/src/features/user/components/profileTab.tsx b/apps/platform/src/features/user/components/profileTab.tsx new file mode 100644 index 0000000..6ddf9da --- /dev/null +++ b/apps/platform/src/features/user/components/profileTab.tsx @@ -0,0 +1,106 @@ +import { Avatar, Button, Input } from "@opencircle/ui"; +import { useEditProfile } from "../hooks/useEditProfile"; + +export const ProfileTab = () => { + const { + account, + isAccountLoading, + isAccountError, + accountError, + formData, + updateMutation, + fileInputRef, + handleSubmit, + handleChange, + handleSocialChange, + handleUpload, + handleFileChange, + } = useEditProfile(); + + if (isAccountLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (isAccountError || !account) { + return ( +
+
+ Error loading account: {accountError?.message || "Account not found"} +
+
+ ); + } + + return ( +
+
+ + + +
+ +
+ handleChange("name", e.target.value)} + placeholder="Name" + /> +