Skip to content

Commit

Permalink
Feat/end session (#305)
Browse files Browse the repository at this point in the history
  • Loading branch information
rickimoore authored Feb 11, 2025
1 parent 9ed799d commit cf538e8
Show file tree
Hide file tree
Showing 20 changed files with 175 additions and 24 deletions.
30 changes: 30 additions & 0 deletions app/api/logout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server'
import fetchFromApi from "../../../utilities/fetchFromApi";
import getReqAuthToken from "../../../utilities/getReqAuthToken";

const backendUrl = process.env.BACKEND_URL

export async function POST(req: Request) {
try {
const token = getReqAuthToken(req)
const { status } = await fetchFromApi(`${backendUrl}/logout`, token, {
method: 'POST'
})

const response = NextResponse.json(true, { status })

response.cookies.delete('session-token')

return response

} catch (error: any) {
let message = error?.response?.data?.message

if (!message) {
message = 'authPrompt.unableToEndSession'
}

console.log(error)
return NextResponse.json({ error: message }, { status: 500 })
}
}
53 changes: 39 additions & 14 deletions app/dashboard/settings/Main.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,31 @@
'use client'

import React, { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import axios from "axios";
import {useRouter} from "next/navigation";
import React, {FC, useState} from 'react'
import {useTranslation} from 'react-i18next'
import LighthouseSvg from '../../../src/assets/images/lighthouse-black.svg'
import AppDescription from '../../../src/components/AppDescription/AppDescription'
import AppVersion from '../../../src/components/AppVersion/AppVersion'
import Button, {ButtonFace} from "../../../src/components/Button/Button";
import DashboardWrapper from '../../../src/components/DashboardWrapper/DashboardWrapper'
import Input from '../../../src/components/Input/Input'
import SocialIcon from '../../../src/components/SocialIcon/SocialIcon'
import Toggle from '../../../src/components/Toggle/Toggle'
import Typography from '../../../src/components/Typography/Typography'
import UiModeIcon from '../../../src/components/UiModeIcon/UiModeIcon'
import {
DiscordUrl,
LighthouseBookUrl,
SigPGithubUrl,
SigPIoUrl,
SigPTwitter,
} from '../../../src/constants/constants'
import { UiMode } from '../../../src/constants/enums'
import {DiscordUrl, LighthouseBookUrl, SigPGithubUrl, SigPIoUrl, SigPTwitter,} from '../../../src/constants/constants'
import {UiMode} from '../../../src/constants/enums'
import useLocalStorage from '../../../src/hooks/useLocalStorage'
import useNetworkMonitor from '../../../src/hooks/useNetworkMonitor'
import useSWRPolling from '../../../src/hooks/useSWRPolling'
import useUiMode from '../../../src/hooks/useUiMode'
import { ActivityResponse, OptionalString } from '../../../src/types'
import { BeaconNodeSpecResults, SyncData } from '../../../src/types/beacon'
import { Diagnostics } from '../../../src/types/diagnostic'
import { UsernameStorage } from '../../../src/types/storage'
import {ActivityResponse, OptionalString, ToastType} from '../../../src/types'
import {BeaconNodeSpecResults, SyncData} from '../../../src/types/beacon'
import {Diagnostics} from '../../../src/types/diagnostic'
import {UsernameStorage} from '../../../src/types/storage'
import addClassString from '../../../utilities/addClassString'
import displayToast from "../../../utilities/displayToast";

export interface MainProps {
initNodeHealth: Diagnostics
Expand All @@ -49,7 +47,9 @@ const Main: FC<MainProps> = (props) => {
initActivityData,
} = props

const router = useRouter()
const { SECONDS_PER_SLOT } = beaconSpec
const [isLoading, setIsLoading] = useState(false)
const { isValidatorError, isBeaconError } = useNetworkMonitor()
const { mode, toggleUiMode } = useUiMode()
const [userNameError, setError] = useState<OptionalString>()
Expand All @@ -66,6 +66,28 @@ const Main: FC<MainProps> = (props) => {
storeUserName(value)
}

const handleError = () => {
displayToast(t('authPrompt.unexpectedErrorLogout'), ToastType.ERROR)

}

const logout = async () => {
try {
setIsLoading(true)
const { status } = await axios.post('/api/logout' )

if(status === 200) {
router.push('/')
return
}
handleError()
} catch (e) {
handleError()
} finally {
setIsLoading(false)
}
}

const networkError = isValidatorError || isBeaconError
const slotInterval = SECONDS_PER_SLOT * 1000
const { data: nodeHealth } = useSWRPolling<Diagnostics>('/api/node-health', {
Expand Down Expand Up @@ -193,6 +215,9 @@ const Main: FC<MainProps> = (props) => {
/>
</div>
</div>
<div className="pt-8">
<Button isLoading={isLoading} onClick={logout} type={ButtonFace.ERROR}>{t('endSession')}</Button>
</div>
</div>
</div>
</DashboardWrapper>
Expand Down
1 change: 1 addition & 0 deletions app/error/Main.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client'

import '../../src/i18n'
import { useTranslation } from 'react-i18next'
import Button, { ButtonFace } from '../../src/components/Button/Button'
import Typography from '../../src/components/Typography/Typography'
Expand Down
3 changes: 2 additions & 1 deletion backend/src/activity/activity.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { ActivityController } from './activity.controller';
import { ActivityService } from './activity.service';
import { SequelizeModule } from '@nestjs/sequelize';
import { Activity } from './entities/activity.entity';
import {AuthModule} from "../auth.module";

@Module({
imports: [SequelizeModule.forFeature([Activity])],
imports: [SequelizeModule.forFeature([Activity]), AuthModule],
controllers: [ActivityController],
providers: [ActivityService],
exports: [ActivityService],
Expand Down
2 changes: 2 additions & 0 deletions backend/src/activity/tests/activity.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { HttpService } from '@nestjs/axios';
import { Sequelize } from 'sequelize-typescript';
import { JwtModule } from '@nestjs/jwt';
import { ActivityType } from '../../../../src/types';
import {AuthModule} from "../../auth.module";

describe('ActivityController', () => {
const baseRowData = {
Expand Down Expand Up @@ -58,6 +59,7 @@ describe('ActivityController', () => {
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
AuthModule,
SequelizeModule.forFeature([Activity]),
SequelizeModule.forRoot({
dialect: 'sqlite',
Expand Down
2 changes: 2 additions & 0 deletions backend/src/app.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AppController } from './app.controller';
import { AppService } from './app.service';
import { JwtModule } from '@nestjs/jwt';
import { UnauthorizedException } from '@nestjs/common';
import {CacheModule} from "@nestjs/cache-manager";

describe('AppController', () => {
let appController: AppController;
Expand All @@ -11,6 +12,7 @@ describe('AppController', () => {
jest.resetModules();
const app: TestingModule = await Test.createTestingModule({
imports: [
CacheModule.register(),
JwtModule.register({
global: true,
secret: 'fake-value',
Expand Down
14 changes: 13 additions & 1 deletion backend/src/app.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import {Body, Controller, HttpCode, HttpStatus, Post, UseGuards, Req} from '@nestjs/common';
import { AppService } from './app.service';
import {SessionGuard} from "./session.guard";

@Controller()
export class AppController {
Expand All @@ -10,4 +11,15 @@ export class AppController {
authenticate(@Body() data: Record<string, any>) {
return this.appService.authenticateSessionPassword(data.password);
}

@Post('/logout')
@UseGuards(SessionGuard)
async logoutSession(@Req() req: Request) {
const authHeader = req.headers['authorization'];
if (!authHeader) {
throw new Error('No Authorization header');
}
const token = authHeader.split(' ')[1];
return await this.appService.invalidateToken(token);
}
}
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { TasksModule } from './tasks/tasks.module';
import { JwtModule } from '@nestjs/jwt';
import { GracefulShutdownModule } from 'nestjs-graceful-shutdown';
import { ActivityModule } from './activity/activity.module';
import {CacheModule} from "@nestjs/cache-manager";

@Module({
imports: [
Expand All @@ -26,6 +27,7 @@ import { ActivityModule } from './activity/activity.module';
secret: process.env.API_TOKEN,
signOptions: { expiresIn: '7200s' }, //set to 2 hours
}),
CacheModule.register(),
ScheduleModule.forRoot(),
LogsModule,
TasksModule,
Expand Down
41 changes: 38 additions & 3 deletions backend/src/app.service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,45 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import {Inject, Injectable, UnauthorizedException} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import {CACHE_MANAGER} from "@nestjs/cache-manager";
import {Cache} from "cache-manager";
import { v4 as uuidV4 } from 'uuid'

@Injectable()
export class AppService {
constructor(private jwtService: JwtService) {}
constructor(
private jwtService: JwtService,
@Inject(CACHE_MANAGER)
private cacheManager: Cache
) {}
private sessionPassword = process.env.SESSION_PASSWORD;

async invalidateToken(token: string) {
const decoded = this.jwtService.decode(token) as any;
if (!decoded || !decoded.exp || !decoded.jti) {
throw new UnauthorizedException('Invalid token');
}

const expiresIn = decoded.exp * 1000 - Date.now();

if (expiresIn <= 0) {
return { message: 'Token is already expired' };
}

await this.cacheManager.set(`blacklist:${decoded.jti}`, 'blacklisted', expiresIn);

return { message: 'Token invalidated' };
}

async isTokenBlacklisted(token: string): Promise<boolean> {
const decoded = this.jwtService.decode(token) as any;
if (!decoded || !decoded.jti) {
return false;
}

const result = await this.cacheManager.get(`blacklist:${decoded.jti}`);
return result === 'blacklisted';
}

async authenticateSessionPassword(password: string) {
if (!this.sessionPassword) {
throw new Error('authPrompt.noPasswordFound');
Expand All @@ -15,7 +49,8 @@ export class AppService {
throw new UnauthorizedException('authPrompt.invalidPassword');
}

const payload = { sub: 'authenticated_session' };
const jti = uuidV4().toString();
const payload = { sub: 'authenticated_session', jti };

return {
access_token: await this.jwtService.signAsync(payload),
Expand Down
17 changes: 17 additions & 0 deletions backend/src/auth.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { AppService } from './app.service';
import { JwtModule } from '@nestjs/jwt';
import { CacheModule } from '@nestjs/cache-manager';

@Module({
imports: [
JwtModule.register({
secret: process.env.API_TOKEN,
signOptions: { expiresIn: '7200s' },
}),
CacheModule.register(),
],
providers: [AppService],
exports: [AppService],
})
export class AuthModule {}
3 changes: 2 additions & 1 deletion backend/src/beacon/beacon.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { BeaconService } from './beacon.service';
import { BeaconController } from './beacon.controller';
import { UtilsModule } from '../utils/utils.module';
import { CacheModule } from '@nestjs/cache-manager';
import {AuthModule} from "../auth.module";

@Module({
imports: [UtilsModule, CacheModule.register()],
imports: [UtilsModule, CacheModule.register(), AuthModule],
controllers: [BeaconController],
providers: [BeaconService],
})
Expand Down
2 changes: 2 additions & 0 deletions backend/src/beacon/tests/beacon.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
mockValCacheValues,
} from '../../../../src/mocks/beacon';
import { StatusColor } from '../../../../src/types';
import {AuthModule} from "../../auth.module";

describe('BeaconController', () => {
let controller: BeaconController;
Expand All @@ -33,6 +34,7 @@ describe('BeaconController', () => {
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
AuthModule,
UtilsModule,
CacheModule.register(),
JwtModule.register({
Expand Down
3 changes: 2 additions & 1 deletion backend/src/logs/logs.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { UtilsModule } from '../utils/utils.module';
import { LogsService } from './logs.service';
import { SequelizeModule } from '@nestjs/sequelize';
import { Log } from './entities/log.entity';
import {AuthModule} from "../auth.module";

@Module({
imports: [UtilsModule, SequelizeModule.forFeature([Log])],
imports: [UtilsModule, SequelizeModule.forFeature([Log]), AuthModule],
controllers: [LogsController],
providers: [LogsService],
exports: [LogsService],
Expand Down
2 changes: 2 additions & 0 deletions backend/src/logs/tests/logs.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
mockErrorLog,
mockWarningLog,
} from '../../../../src/mocks/logs';
import {AuthModule} from "../../auth.module";

describe('LogsController', () => {
let logsService: LogsService;
Expand All @@ -34,6 +35,7 @@ describe('LogsController', () => {
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
AuthModule,
UtilsModule,
SequelizeModule.forFeature([Log]),
SequelizeModule.forRoot({
Expand Down
3 changes: 2 additions & 1 deletion backend/src/node/node.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { NodeService } from './node.service';
import { UtilsModule } from '../utils/utils.module';
import { NodeController } from './node.controller';
import { CacheModule } from '@nestjs/cache-manager';
import {AuthModule} from "../auth.module";

@Module({
imports: [UtilsModule, CacheModule.register()],
imports: [UtilsModule, CacheModule.register(), AuthModule],
controllers: [NodeController],
providers: [NodeService],
})
Expand Down
2 changes: 2 additions & 0 deletions backend/src/node/tests/node.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { NodeService } from '../node.service';
import { AxiosResponse } from 'axios/index';
import { of } from 'rxjs';
import { mockDiagnostics } from '../../../../src/mocks/beaconSpec';
import {AuthModule} from "../../auth.module";

describe('NodeController', () => {
let controller: NodeController;
Expand All @@ -28,6 +29,7 @@ describe('NodeController', () => {
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
AuthModule,
UtilsModule,
CacheModule.register(),
JwtModule.register({
Expand Down
Loading

0 comments on commit cf538e8

Please sign in to comment.