diff --git a/backend/package.json b/backend/package.json index daa2f36e..cc0d7363 100644 --- a/backend/package.json +++ b/backend/package.json @@ -87,6 +87,7 @@ "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "^5.7.3", - "typescript-eslint": "^8.20.0" + "typescript-eslint": "^8.20.0", + "yn": "^3.1.1" } } diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 726a2cba..187e7095 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -23,7 +23,8 @@ import { ForgotPasswordProvider } from './providers/forgot-password.provider'; import { ResetPasswordProvider } from './providers/reset-password.provider'; import { MailService } from './providers/mail.service'; import { NonceService } from './providers/nonce.service'; - +import { Session } from './entities/session.entity'; +import { SessionsProvider } from './providers/sessions.provider'; import { GuestSessionProvider } from './providers/guest-session.provider'; @Module({ @@ -31,6 +32,7 @@ import { GuestSessionProvider } from './providers/guest-session.provider'; forwardRef(() => UsersModule), TypeOrmModule.forFeature([User]), ConfigModule.forFeature(jwtConfig), + TypeOrmModule.forFeature([Session]), JwtModule.registerAsync(jwtConfig.asProvider()), ThrottlerModule.forRoot([ { @@ -54,6 +56,7 @@ import { GuestSessionProvider } from './providers/guest-session.provider'; ResetPasswordProvider, MailService, NonceService, + SessionsProvider, { provide: HashingProvider, // Use the abstract class as a token useClass: BcryptProvider, // Bind it to the concrete implementation diff --git a/backend/src/auth/authConfig/jwt.config.ts b/backend/src/auth/authConfig/jwt.config.ts index 27809d47..a20a018c 100644 --- a/backend/src/auth/authConfig/jwt.config.ts +++ b/backend/src/auth/authConfig/jwt.config.ts @@ -7,6 +7,7 @@ export default registerAs('jwt', () => { googleClient_id: process.env.GOOGLE_CLIENT_ID, googleClient_secret: process.env.GOOGLE_CLIENT_SECRET, issuer: process.env.JWT_TOKEN_ISSUER ?? 'localhost', - ttl: parseInt(process.env.JWT_ACCESS_TOKEN_TTL ?? '3600'), + accessTokenTtl: parseInt(process.env.JWT_ACCESS_TOKEN_TTL ?? '3600'), // 1 hour default + refreshTokenTtl: parseInt(process.env.JWT_REFRESH_TOKEN_TTL ?? '604800'), // 7 days default }; -}); +}); \ No newline at end of file diff --git a/backend/src/auth/controllers/auth.controller.ts b/backend/src/auth/controllers/auth.controller.ts index 32ca3b32..9c42a607 100644 --- a/backend/src/auth/controllers/auth.controller.ts +++ b/backend/src/auth/controllers/auth.controller.ts @@ -7,17 +7,22 @@ import { Get, Query, Param, + Req, + UseGuards, } from '@nestjs/common'; import { Throttle } from '@nestjs/throttler'; import { LoginDto } from '../dtos/login.dto'; import { RegisterDto } from '../dtos/register.dto'; import { AuthService } from '../providers/auth.service'; import { RefreshTokenDto } from '../dtos/refreshTokenDto'; -import { ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { NonceResponseDto } from '../dtos/nonceResponse.dto'; import { StellarWalletLoginDto } from '../dtos/walletLogin.dto'; import { ResetPasswordDto } from '../dtos/reset-password.dto'; import { ForgotPasswordDto } from '../dtos/forgot-password.dto'; +import { AuthGuard } from '@nestjs/passport'; +import { ActiveUser } from '../decorators/activeUser.decorator'; +import { ActiveUserData } from '../interfaces/activeInterface'; import { GuestSessionProvider } from '../providers/guest-session.provider'; import { ConvertGuestDto } from '../dtos/convert-guest.dto'; @@ -210,32 +215,40 @@ export class AuthController { } @Post('/reset-password/:token') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Reset password with token', - description: 'Resets user password using the token from email', - }) - @ApiResponse({ - status: 200, - description: 'Password reset successfully', - schema: { - type: 'object', - properties: { - message: { - type: 'string', - example: 'Password has been reset successfully', - }, - }, - }, - }) - @ApiResponse({ - status: 400, - description: 'Invalid or expired token', - }) public async resetPassword( @Param('token') token: string, @Body() resetPasswordDto: ResetPasswordDto, ) { return await this.authservice.resetPassword(token, resetPasswordDto); } + + @Post('/logout') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Logout current user and invalidate session' }) + @ApiResponse({ status: 200, description: 'Successfully logged out' }) + @ApiResponse({ status: 401, description: 'Invalid or missing refresh token' }) + public async logout(@Body() refreshTokenDto: RefreshTokenDto) { + return await this.authservice.logout(refreshTokenDto); + } + + @Post('/logout-all') + @UseGuards(AuthGuard('jwt')) + @ApiBearerAuth() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Logout from all devices' }) + @ApiResponse({ status: 200, description: 'All sessions invalidated' }) + public async logoutAll(@ActiveUser() user: ActiveUserData) { + return await this.authservice.logoutAll(user.sub); + } + + @Get('/me') + @UseGuards(AuthGuard('jwt')) + @ApiBearerAuth() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Get current authenticated user' }) + @ApiResponse({ status: 200, description: 'Current user data retrieved' }) + @ApiResponse({ status: 401, description: 'Unauthorized - invalid or missing token' }) + public async getCurrentUser(@ActiveUser() user: ActiveUserData) { + return await this.authservice.getCurrentUser(user.sub); + } } \ No newline at end of file diff --git a/backend/src/auth/entities/session.entity.ts b/backend/src/auth/entities/session.entity.ts new file mode 100644 index 00000000..3fd4c10f --- /dev/null +++ b/backend/src/auth/entities/session.entity.ts @@ -0,0 +1,42 @@ +import { + Column, + Entity, + PrimaryGeneratedColumn, + ManyToOne, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { User } from '../../users/user.entity'; + +@Entity() +export class Session { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + user: User; + + @Column() + userId: string; + + @Column({ unique: true }) + refreshTokenHash: string; + + @Column({ nullable: true }) + deviceInfo?: string; + + @Column({ nullable: true }) + ipAddress?: string; + + @Column({ type: 'timestamp' }) + expiresAt: Date; + + @Column({ default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} \ No newline at end of file diff --git a/backend/src/auth/providers/auth.service.ts b/backend/src/auth/providers/auth.service.ts index 05fb1e65..52e36101 100644 --- a/backend/src/auth/providers/auth.service.ts +++ b/backend/src/auth/providers/auth.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; import { LoginDto } from '../dtos/login.dto'; import { RegisterDto } from '../dtos/register.dto'; import { SignInProvider } from './sign-in.provider'; @@ -14,6 +14,8 @@ import { ResetPasswordProvider } from './reset-password.provider'; import { ForgotPasswordDto } from '../dtos/forgot-password.dto'; import { ResetPasswordDto } from '../dtos/reset-password.dto'; import { NonceService } from './nonce.service'; +import { SessionsProvider } from './sessions.provider'; +import { UsersService } from '../../users/providers/users.service'; @Injectable() export class AuthService { @@ -52,6 +54,16 @@ export class AuthService { * inject nonceService */ private readonly nonceService: NonceService, + + /** + * Inject SessionsProvider for secure session management + */ + private readonly sessionsProvider: SessionsProvider, + + /** + * Inject UsersService to retrieve user data + */ + private readonly usersService: UsersService, ) {} public async register(registerDto: RegisterDto) { @@ -150,4 +162,33 @@ export class AuthService { resetPasswordDto, ); } + + /** + * Logout current user by invalidating their refresh token + */ + public async logout(refreshTokenDto: RefreshTokenDto) { + await this.sessionsProvider.invalidateSession(refreshTokenDto.refreshToken); + return { message: 'Successfully logged out' }; + } + + /** + * Invalidate all sessions for a user (logout from all devices) + */ + public async logoutAll(userId: string) { + await this.sessionsProvider.invalidateAllUserSessions(userId); + return { message: 'All sessions invalidated successfully' }; + } + + /** + * Get current authenticated user data + */ + public async getCurrentUser(userId: string) { + const user = await this.usersService.findOneById(userId); + if (!user) { + throw new UnauthorizedException('User not found'); + } + // Return user without sensitive data + const { password, passwordResetToken, passwordResetExpires, ...userWithoutSensitiveData } = user; + return userWithoutSensitiveData; + } } \ No newline at end of file diff --git a/backend/src/auth/providers/generate-tokens.provider.ts b/backend/src/auth/providers/generate-tokens.provider.ts index 255941da..0147ef4d 100644 --- a/backend/src/auth/providers/generate-tokens.provider.ts +++ b/backend/src/auth/providers/generate-tokens.provider.ts @@ -72,12 +72,12 @@ export class GenerateTokensProvider { } const [accessToken, refreshToken] = await Promise.all([ - this.signToken(user.id, user.username, this.jwtConfiguration.ttl, { + this.signToken(user.id, user.username, this.jwtConfiguration.accessTokenTtl, { email: user.email, }), - this.signToken(user.id, user.username, this.jwtConfiguration.ttl), + this.signToken(user.id, user.username, this.jwtConfiguration.refreshTokenTtl), ]); return { accessToken, refreshToken, user }; } -} +} \ No newline at end of file diff --git a/backend/src/auth/providers/refreshTokensProvider.ts b/backend/src/auth/providers/refreshTokensProvider.ts index ddaaa8ae..133ef1dc 100644 --- a/backend/src/auth/providers/refreshTokensProvider.ts +++ b/backend/src/auth/providers/refreshTokensProvider.ts @@ -1,16 +1,7 @@ -import { - forwardRef, - Inject, - Injectable, - UnauthorizedException, -} from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { RefreshTokenDto } from '../dtos/refreshTokenDto'; -import { JwtService } from '@nestjs/jwt'; -import { ConfigType } from '@nestjs/config'; -import jwtConfig from '../authConfig/jwt.config'; -import { GenerateTokensProvider } from './generate-tokens.provider'; import { ApiTags, ApiOperation, ApiBody } from '@nestjs/swagger'; -import { UsersService } from '../../users/providers/users.service'; +import { SessionsProvider } from './sessions.provider'; /** * Refresh token provider class @@ -20,26 +11,9 @@ import { UsersService } from '../../users/providers/users.service'; export class RefreshTokensProvider { constructor( /** - * Injecting UserService repository + * Injecting SessionsProvider for secure session management */ - @Inject(forwardRef(() => UsersService)) - private readonly userService: UsersService, - - /** - * Injecting JwtService - */ - private readonly jwtService: JwtService, - - /** - * Injecting JWT Configuration - */ - @Inject(jwtConfig.KEY) - private readonly jwtConfiguration: ConfigType, - - /** - * Injecting GenerateTokensProvider - */ - private readonly generateTokenProvider: GenerateTokensProvider, + private readonly sessionsProvider: SessionsProvider, ) {} /** @@ -49,28 +23,16 @@ export class RefreshTokensProvider { */ @ApiOperation({ summary: 'Refresh authentication tokens' }) @ApiBody({ type: RefreshTokenDto }) - public async refreshTokens(refreshTokenDto: RefreshTokenDto) { - // Validate the refresh token using JWT - const payload = await this.jwtService.verifyAsync<{ sub: string }>( + public async refreshTokens( + refreshTokenDto: RefreshTokenDto, + deviceInfo?: string, + ipAddress?: string, + ) { + // Use sessions provider to validate and rotate the refresh token + return await this.sessionsProvider.refreshSession( refreshTokenDto.refreshToken, - { - secret: this.jwtConfiguration.secret, - audience: this.jwtConfiguration.audience, - issuer: this.jwtConfiguration.issuer, - }, + deviceInfo, + ipAddress, ); - - const sub = payload.sub; - - // Retrieve the user from the database - const user = await this.userService.findOneByGoogleId(sub); - - // inside refreshTokens - if (!user) { - throw new UnauthorizedException('Invalid refresh token'); - } - - // Generate new tokens - return await this.generateTokenProvider.generateTokens(user); } -} +} \ No newline at end of file diff --git a/backend/src/auth/providers/sessions.provider.ts b/backend/src/auth/providers/sessions.provider.ts new file mode 100644 index 00000000..a9577aec --- /dev/null +++ b/backend/src/auth/providers/sessions.provider.ts @@ -0,0 +1,156 @@ +import { + Injectable, + UnauthorizedException, + Inject, + forwardRef, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Session } from '../entities/session.entity'; +import { User } from '../../users/user.entity'; +import * as crypto from 'crypto'; +import { ConfigType } from '@nestjs/config'; +import jwtConfig from '../authConfig/jwt.config'; +import { GenerateTokensProvider } from './generate-tokens.provider'; +import { UsersService } from '../../users/providers/users.service'; + +@Injectable() +export class SessionsProvider { + constructor( + @InjectRepository(Session) + private readonly sessionRepository: Repository, + + @Inject(jwtConfig.KEY) + private readonly jwtConfiguration: ConfigType, + + private readonly generateTokensProvider: GenerateTokensProvider, + + @Inject(forwardRef(() => UsersService)) + private readonly usersService: UsersService, + ) {} + + /** + * Hash a refresh token for secure storage + */ + private hashRefreshToken(refreshToken: string): string { + return crypto.createHash('sha256').update(refreshToken).digest('hex'); + } + + /** + * Create a new session for a user + */ + public async createSession( + user: User, + deviceInfo?: string, + ipAddress?: string, + ): Promise<{ accessToken: string; refreshToken: string; user: User }> { + // Generate new tokens + const tokens = await this.generateTokensProvider.generateTokens(user); + + // Calculate refresh token expiration + const expiresAt = new Date(); + expiresAt.setSeconds( + expiresAt.getSeconds() + this.jwtConfiguration.refreshTokenTtl, + ); + + // Hash the refresh token before storing + const refreshTokenHash = this.hashRefreshToken(tokens.refreshToken); + + // Create and save the session + const session = this.sessionRepository.create({ + userId: user.id, + refreshTokenHash, + deviceInfo, + ipAddress, + expiresAt, + isActive: true, + }); + + await this.sessionRepository.save(session); + + return tokens; + } + + /** + * Validate and rotate refresh token (token rotation for security) + */ + public async refreshSession( + refreshToken: string, + deviceInfo?: string, + ipAddress?: string, + ): Promise<{ accessToken: string; refreshToken: string; user: User }> { + const refreshTokenHash = this.hashRefreshToken(refreshToken); + + // Find the session with matching hash that is still active and not expired + const session = await this.sessionRepository.findOne({ + where: { + refreshTokenHash, + isActive: true, + }, + relations: ['user'], + }); + + if (!session) { + throw new UnauthorizedException('Invalid refresh token'); + } + + if (new Date() > session.expiresAt) { + // Invalidate the expired session + session.isActive = false; + await this.sessionRepository.save(session); + throw new UnauthorizedException('Refresh token has expired'); + } + + // Invalidate the old session (token rotation) + session.isActive = false; + await this.sessionRepository.save(session); + + // Create a new session with new tokens + return this.createSession(session.user, deviceInfo, ipAddress); + } + + /** + * Invalidate a specific session (logout) + */ + public async invalidateSession(refreshToken: string): Promise { + const refreshTokenHash = this.hashRefreshToken(refreshToken); + const session = await this.sessionRepository.findOne({ + where: { refreshTokenHash }, + }); + + if (session) { + session.isActive = false; + await this.sessionRepository.save(session); + } + } + + /** + * Invalidate all sessions for a user (logout from all devices) + */ + public async invalidateAllUserSessions(userId: string): Promise { + await this.sessionRepository.update( + { userId, isActive: true }, + { isActive: false }, + ); + } + + /** + * Get all active sessions for a user + */ + public async getUserActiveSessions(userId: string): Promise { + return this.sessionRepository.find({ + where: { userId, isActive: true }, + order: { createdAt: 'DESC' }, + }); + } + + /** + * Clean up expired sessions (can be run as a cron job) + */ + public async cleanupExpiredSessions(): Promise { + await this.sessionRepository.update( + { expiresAt: new Date(), isActive: true }, + { isActive: false }, + ); + } +} \ No newline at end of file diff --git a/backend/src/auth/providers/sign-in.provider.ts b/backend/src/auth/providers/sign-in.provider.ts index 11dc19c0..5dccb105 100644 --- a/backend/src/auth/providers/sign-in.provider.ts +++ b/backend/src/auth/providers/sign-in.provider.ts @@ -5,15 +5,13 @@ import { RequestTimeoutException, UnauthorizedException, } from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; -import { ConfigType } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { UsersService } from '../../users/providers/users.service'; import { User } from '../../users/user.entity'; import { HashingProvider } from './hashing.provider'; -import jwtConfig from '../authConfig/jwt.config'; import { LoginDto } from '../dtos/login.dto'; +import { SessionsProvider } from './sessions.provider'; @Injectable() export class SignInProvider { @@ -29,12 +27,8 @@ export class SignInProvider { // injecting hashing dependency private readonly hashingProvider: HashingProvider, - // inject jwt service - private readonly jwtService: JwtService, - - // inject jwt - @Inject(jwtConfig.KEY) - private readonly jwtConfiguration: ConfigType, + // inject sessions provider for secure session management + private readonly sessionsProvider: SessionsProvider, ) {} public async SignIn(signInDto: LoginDto) { // Always use the same error message to prevent email enumeration @@ -74,19 +68,7 @@ export class SignInProvider { }); } - const accessToken = await this.jwtService.signAsync( - { - sub: user.id, - email: user.email, - }, - { - audience: this.jwtConfiguration.audience, - issuer: this.jwtConfiguration.issuer, - expiresIn: this.jwtConfiguration.ttl, - }, - ); - - // login - return { accessToken }; + // Create a secure session and return the tokens expected by the frontend + return await this.sessionsProvider.createSession(user); } } \ No newline at end of file diff --git a/backend/src/auth/providers/wallet-login.provider.ts b/backend/src/auth/providers/wallet-login.provider.ts index f2b93f90..def524f0 100644 --- a/backend/src/auth/providers/wallet-login.provider.ts +++ b/backend/src/auth/providers/wallet-login.provider.ts @@ -88,7 +88,7 @@ export class StellarWalletLoginProvider { { audience: this.jwtConfiguration.audience, issuer: this.jwtConfiguration.issuer, - expiresIn: this.jwtConfiguration.ttl, + expiresIn: this.jwtConfiguration.accessTokenTtl, }, ); @@ -180,4 +180,4 @@ export class StellarWalletLoginProvider { ); } } -} +} \ No newline at end of file diff --git a/backend/src/database/migrations/20260821000000-CreateSessionsTable.ts b/backend/src/database/migrations/20260821000000-CreateSessionsTable.ts new file mode 100644 index 00000000..8e00ca8e --- /dev/null +++ b/backend/src/database/migrations/20260821000000-CreateSessionsTable.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateSessionsTable20260821000000 implements MigrationInterface { + name = 'CreateSessionsTable20260821000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "session" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "userId" INTEGER NOT NULL, + "refreshTokenHash" VARCHAR NOT NULL UNIQUE, + "deviceInfo" VARCHAR, + "ipAddress" VARCHAR, + "expiresAt" TIMESTAMP NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY ("id"), + CONSTRAINT "FK_session_user" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP TABLE "session"; + `); + } +} \ No newline at end of file diff --git a/frontend/app/auth/signin/page.tsx b/frontend/app/auth/signin/page.tsx index 3c1e3ea9..d8fd1443 100644 --- a/frontend/app/auth/signin/page.tsx +++ b/frontend/app/auth/signin/page.tsx @@ -155,18 +155,15 @@ const SignInPage = () => { // Parse JSON only if response is ok const data = await response.json(); - if (data.accessToken) { - // Store token in localStorage - localStorage.setItem('accessToken', data.accessToken); - - // Update Redux state + if (data.accessToken && data.refreshToken) { + // Update Redux state with both tokens const user = { id: data.user?.id || formData.username, email: formData.username, username: data.user?.username || formData.username.split('@')[0], }; - loginSuccess(user, data.accessToken); + loginSuccess(user, data.accessToken, data.refreshToken); // Show success toast showSuccess('Login Successful', 'Welcome back!'); @@ -367,4 +364,4 @@ const SignInPage = () => { ); }; -export default SignInPage; +export default SignInPage; \ No newline at end of file diff --git a/frontend/components/ClientLayout.tsx b/frontend/components/ClientLayout.tsx index afc94faa..80f33071 100644 --- a/frontend/components/ClientLayout.tsx +++ b/frontend/components/ClientLayout.tsx @@ -1,9 +1,10 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import SideNav from "@/components/SideNav"; import { Menu } from "lucide-react"; import ErrorBoundary from "@/components/error/ErrorBoundary"; +import { useAuth } from "@/hooks/useAuth"; export default function ClientLayout({ children, @@ -11,6 +12,28 @@ export default function ClientLayout({ children: React.ReactNode; }) { const [sidebarOpen, setSidebarOpen] = useState(false); + const { restoreSession } = useAuth(); + + // Restore session on app initialization + useEffect(() => { + restoreSession(); + }, [restoreSession]); + + // Set up automatic token refresh + const { refreshToken: refreshTokenAction, isAuthenticated, token } = useAuth(); + + useEffect(() => { + if (!isAuthenticated || !token) return; + + // Refresh token 5 minutes before it expires (assuming 1 hour expiration) + const REFRESH_INTERVAL = 55 * 60 * 1000; // 55 minutes + + const intervalId = setInterval(() => { + refreshTokenAction(); + }, REFRESH_INTERVAL); + + return () => clearInterval(intervalId); + }, [isAuthenticated, token, refreshTokenAction]); return (
@@ -31,4 +54,4 @@ export default function ClientLayout({
); -} +} \ No newline at end of file diff --git a/frontend/components/SideNav.tsx b/frontend/components/SideNav.tsx index 5b1a9197..e24c8a1e 100644 --- a/frontend/components/SideNav.tsx +++ b/frontend/components/SideNav.tsx @@ -2,7 +2,8 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { Home, Trophy, User, Bell } from "lucide-react"; +import { Home, Trophy, User, Bell, LogOut } from "lucide-react"; +import { useAuth } from "@/hooks/useAuth"; const navItems = [ { label: "Home", href: "/dashboard", icon: Home }, @@ -14,6 +15,7 @@ const navItems = [ const SideNav = ({ open, onClose }: { open: boolean; onClose: () => void }) => { const pathname = usePathname(); + const { logout, isAuthenticated } = useAuth(); return ( ); }; -export default SideNav; +export default SideNav; \ No newline at end of file diff --git a/frontend/hooks/useAuth.ts b/frontend/hooks/useAuth.ts index 5b56979d..49a74c1d 100644 --- a/frontend/hooks/useAuth.ts +++ b/frontend/hooks/useAuth.ts @@ -8,6 +8,7 @@ import { selectAuthLoading, selectAuthError, selectToken, + selectRefreshToken, selectIsRestoring, loginSuccess, loginFailure, @@ -32,20 +33,38 @@ export function useAuth() { const isLoading = useAppSelector(selectAuthLoading); const error = useAppSelector(selectAuthError); const token = useAppSelector(selectToken); + const refreshTokenValue = useAppSelector(selectRefreshToken); const isRestoring = useAppSelector(selectIsRestoring); // Actions - const handleLoginSuccess = useCallback((user: User, token: string) => { - dispatch(loginSuccess({ user, token })); - }, [dispatch]); + const handleLoginSuccess = useCallback((user: User, token: string, refreshToken?: string) => { + dispatch(loginSuccess({ user, token, refreshToken: refreshToken ?? refreshTokenValue ?? '' })); + }, [dispatch, refreshTokenValue]); const handleLoginFailure = useCallback((error: string) => { dispatch(loginFailure(error)); }, [dispatch]); - const handleLogout = useCallback(() => { + const handleLogout = useCallback(async () => { + if (refreshTokenValue) { + try { + // Call backend logout endpoint to invalidate the session + await fetch('/api/auth/logout', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ refreshToken: refreshTokenValue }), + }); + } catch (error) { + console.error('Logout error:', error); + } + } + // Clear local state regardless of backend response dispatch(logout()); - }, [dispatch]); + // Redirect to signin page + window.location.href = '/auth/signin'; + }, [dispatch, refreshTokenValue]); const handleClearError = useCallback(() => { dispatch(clearError()); @@ -82,8 +101,9 @@ export function useAuth() { isLoading, error, token, + refreshTokenValue, isRestoring, - + // Actions loginSuccess: handleLoginSuccess, loginFailure: handleLoginFailure, @@ -119,6 +139,10 @@ export function useAuthToken() { return useAppSelector(selectToken); } +export function useRefreshToken() { + return useAppSelector(selectRefreshToken); +} + export function useIsRestoring() { return useAppSelector(selectIsRestoring); -} +} \ No newline at end of file diff --git a/frontend/lib/features/auth/authSlice.ts b/frontend/lib/features/auth/authSlice.ts index d465be3b..8f912247 100644 --- a/frontend/lib/features/auth/authSlice.ts +++ b/frontend/lib/features/auth/authSlice.ts @@ -14,6 +14,7 @@ export interface User { export interface AuthState { user: User | null; token: string | null; + refreshToken: string | null; isAuthenticated: boolean; isLoading: boolean; error: string | null; @@ -24,6 +25,7 @@ export interface AuthState { const initialState: AuthState = { user: null, token: typeof window !== 'undefined' ? localStorage.getItem('accessToken') : null, + refreshToken: typeof window !== 'undefined' ? localStorage.getItem('refreshToken') : null, isAuthenticated: typeof window !== 'undefined' ? !!localStorage.getItem('accessToken') : false, isLoading: false, error: null, @@ -42,18 +44,31 @@ export const loginStart = createAsyncThunk( export const restoreSession = createAsyncThunk( 'auth/restoreSession', - async (_, { rejectWithValue }) => { + async (_, { rejectWithValue, getState }) => { try { const token = localStorage.getItem('accessToken'); if (!token) { throw new Error('No token found'); } - // TODO: Validate token with backend and fetch user data - // For now, just return the token - return { token }; - } catch { + // Validate token with backend by fetching user data + const response = await fetch('/api/auth/me', { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Invalid token'); + } + + const user = await response.json(); + return { token, user }; + } catch (error) { + // Clear invalid tokens localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); return rejectWithValue('Session expired'); } } @@ -61,16 +76,41 @@ export const restoreSession = createAsyncThunk( export const refreshToken = createAsyncThunk( 'auth/refreshToken', - async (_, { rejectWithValue }) => { + async (_, { rejectWithValue, getState }) => { try { - // TODO: Implement token refresh logic - const token = localStorage.getItem('accessToken'); - if (!token) { - throw new Error('No token to refresh'); + const refreshTokenValue = localStorage.getItem('refreshToken'); + if (!refreshTokenValue) { + throw new Error('No refresh token found'); } - return { token }; - } catch { + + // Call refresh token endpoint + const response = await fetch('/api/auth/refresh', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ refreshToken: refreshTokenValue }), + }); + + if (!response.ok) { + throw new Error('Failed to refresh token'); + } + + const data = await response.json(); + + // Update stored tokens + localStorage.setItem('accessToken', data.accessToken); + localStorage.setItem('refreshToken', data.refreshToken); + + return { + accessToken: data.accessToken, + refreshToken: data.refreshToken, + user: data.user + }; + } catch (error) { + // Clear invalid tokens localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); return rejectWithValue('Failed to refresh token'); } } @@ -81,16 +121,18 @@ const authSlice = createSlice({ name: 'auth', initialState, reducers: { - loginSuccess: (state, action: PayloadAction<{ user: User; token: string }>) => { + loginSuccess: (state, action: PayloadAction<{ user: User; token: string; refreshToken: string }>) => { state.user = action.payload.user; state.token = action.payload.token; + state.refreshToken = action.payload.refreshToken; state.isAuthenticated = true; state.isLoading = false; state.error = null; - // Store token in localStorage + // Store tokens in localStorage if (typeof window !== 'undefined') { localStorage.setItem('accessToken', action.payload.token); + localStorage.setItem('refreshToken', action.payload.refreshToken); } }, @@ -110,13 +152,15 @@ const authSlice = createSlice({ logout: (state) => { state.user = null; state.token = null; + state.refreshToken = null; state.isAuthenticated = false; state.isLoading = false; state.error = null; - // Remove token from localStorage + // Remove tokens from localStorage if (typeof window !== 'undefined') { localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); } }, @@ -181,12 +225,15 @@ const authSlice = createSlice({ .addCase(restoreSession.fulfilled, (state, action) => { state.isRestoring = false; state.token = action.payload.token; + state.user = action.payload.user; state.isAuthenticated = true; }) .addCase(restoreSession.rejected, (state, action) => { state.isRestoring = false; state.token = null; + state.refreshToken = null; state.isAuthenticated = false; + state.user = null; state.error = action.payload as string || 'Failed to restore session'; }) @@ -197,12 +244,17 @@ const authSlice = createSlice({ }) .addCase(refreshToken.fulfilled, (state, action) => { state.isLoading = false; - state.token = action.payload.token; + state.token = action.payload.accessToken; + state.refreshToken = action.payload.refreshToken; + if (action.payload.user) { + state.user = action.payload.user; + } state.isAuthenticated = true; }) .addCase(refreshToken.rejected, (state, action) => { state.isLoading = false; state.token = null; + state.refreshToken = null; state.isAuthenticated = false; state.user = null; state.error = action.payload as string || 'Failed to refresh token'; @@ -228,7 +280,8 @@ export const selectIsAuthenticated = (state: { auth: AuthState }) => state.auth. export const selectAuthLoading = (state: { auth: AuthState }) => state.auth.isLoading; export const selectAuthError = (state: { auth: AuthState }) => state.auth.error; export const selectToken = (state: { auth: AuthState }) => state.auth.token; +export const selectRefreshToken = (state: { auth: AuthState }) => state.auth.refreshToken; export const selectIsRestoring = (state: { auth: AuthState }) => state.auth.isRestoring; // Reducer -export default authSlice.reducer; +export default authSlice.reducer; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index db6cd8d2..d276245c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -99,7 +99,8 @@ "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "^5.7.3", - "typescript-eslint": "^8.20.0" + "typescript-eslint": "^8.20.0", + "yn": "^3.1.1" } }, "backend/node_modules/@angular-devkit/core": { @@ -247,7 +248,7 @@ }, "backend/node_modules/@swc/core": { "version": "1.13.5", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -718,6 +719,16 @@ } } }, + "backend/node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "frontend": { "version": "0.1.0", "dependencies": { @@ -1583,6 +1594,30 @@ "node": ">=0.1.90" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -3374,7 +3409,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -3395,7 +3430,7 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -4656,6 +4691,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4672,6 +4708,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4688,6 +4725,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4704,6 +4742,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4720,6 +4759,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4736,6 +4776,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4752,6 +4793,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4768,6 +4810,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4784,6 +4827,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4800,6 +4844,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4813,7 +4858,7 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@swc/helpers": { @@ -4829,7 +4874,7 @@ "version": "0.1.25", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3" @@ -5165,6 +5210,34 @@ "node": ">= 6" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.13.tgz", + "integrity": "sha512-gcLdvR9HO1ZJBypsOGqaP6TFEzb6vIta0KSTLt9NAQ6pXQO3cRgSVyCN6pzYqI9DlJgY71XKO0dpDhCf08b3pg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -6474,6 +6547,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -6693,6 +6779,13 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -8223,6 +8316,13 @@ "node": "*" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/cron": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/cron/-/cron-4.3.5.tgz", @@ -8657,6 +8757,16 @@ "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", "license": "MIT" }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -18160,6 +18270,13 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",