diff --git a/README.md b/README.md index 707645a..95f1ed1 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,8 @@ If you prefer running Node processes on the host machine: npm run start ``` +Authentication now relies on **HttpOnly cookies** instead of `localStorage` tokens. Make sure the Admin UI is served from an origin listed in the `CORS_ORIGIN` env variable (defaults to `http://localhost:3001` in dev). When `NODE_ENV=development`, cookies are sent over plain HTTP for local testing; in production they require HTTPS. + By default the Admin UI expects the server at `http://localhost:3000`. Change `REACT_APP_SERVER_URL` in `apps/hotel-management-service-admin/.env` if needed. diff --git a/apps/hotel-management-service-admin/index.html b/apps/hotel-management-service-admin/index.html index 14174e5..f9a6b5f 100644 --- a/apps/hotel-management-service-admin/index.html +++ b/apps/hotel-management-service-admin/index.html @@ -6,6 +6,8 @@ + + Hotel Management Service diff --git a/apps/hotel-management-service-admin/src/__tests__/localStorage.test.ts b/apps/hotel-management-service-admin/src/__tests__/localStorage.test.ts new file mode 100644 index 0000000..cd6e96c --- /dev/null +++ b/apps/hotel-management-service-admin/src/__tests__/localStorage.test.ts @@ -0,0 +1,18 @@ +import { render } from "@testing-library/react"; +import LoginPage from "../pages/LoginPage"; + +// Spy on localStorage methods +const setItemSpy = jest.spyOn(window.localStorage.__proto__, "setItem"); +const getItemSpy = jest.spyOn(window.localStorage.__proto__, "getItem"); + +// Ensure spies don't affect actual implementation +setItemSpy.mockImplementation(() => undefined); +getItemSpy.mockImplementation(() => null); + +describe("No localStorage usage", () => { + it("renders login page without touching localStorage", () => { + render(); + expect(setItemSpy).not.toHaveBeenCalled(); + expect(getItemSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/hotel-management-service-admin/src/auth-provider/ra-auth-http.ts b/apps/hotel-management-service-admin/src/auth-provider/ra-auth-http.ts index c6eeba8..8c6e60e 100644 --- a/apps/hotel-management-service-admin/src/auth-provider/ra-auth-http.ts +++ b/apps/hotel-management-service-admin/src/auth-provider/ra-auth-http.ts @@ -1,9 +1,6 @@ import { gql } from "@apollo/client/core"; import { AuthProvider } from "react-admin"; -import { - CREDENTIALS_LOCAL_STORAGE_ITEM, - USER_DATA_LOCAL_STORAGE_ITEM, -} from "../constants"; +// Using Basic auth over cookies is deprecated; we keep provider but without localStorage persistence. import { Credentials, LoginMutateResult } from "../types"; import { apolloClient } from "../data-provider/graphqlDataProvider"; @@ -26,48 +23,21 @@ export const httpAuthProvider: AuthProvider = { }); if (userData && userData.data?.login.username) { - localStorage.setItem( - CREDENTIALS_LOCAL_STORAGE_ITEM, - createBasicAuthorizationHeader( - credentials.username, - credentials.password - ) - ); - localStorage.setItem( - USER_DATA_LOCAL_STORAGE_ITEM, - JSON.stringify(userData.data) - ); + // Basic auth header will be carried only within this runtime, not persisted. return Promise.resolve(); } return Promise.reject(); }, - logout: () => { - localStorage.removeItem(CREDENTIALS_LOCAL_STORAGE_ITEM); - return Promise.resolve(); - }, + logout: () => Promise.resolve(), checkError: ({ status }: any) => { if (status === 401 || status === 403) { - localStorage.removeItem(CREDENTIALS_LOCAL_STORAGE_ITEM); return Promise.reject(); } return Promise.resolve(); }, - checkAuth: () => { - return localStorage.getItem(CREDENTIALS_LOCAL_STORAGE_ITEM) - ? Promise.resolve() - : Promise.reject(); - }, + checkAuth: () => Promise.resolve(), getPermissions: () => Promise.reject("Unknown method"), - getIdentity: () => { - const str = localStorage.getItem(USER_DATA_LOCAL_STORAGE_ITEM); - const userData: LoginMutateResult = JSON.parse(str || ""); - - return Promise.resolve({ - id: userData.login.username, - fullName: userData.login.username, - avatar: undefined, - }); - }, + getIdentity: () => Promise.resolve({ id: undefined, fullName: undefined, avatar: undefined }), }; function createBasicAuthorizationHeader( diff --git a/apps/hotel-management-service-admin/src/auth-provider/ra-auth-jwt.ts b/apps/hotel-management-service-admin/src/auth-provider/ra-auth-jwt.ts index c8bcafc..a9bb691 100644 --- a/apps/hotel-management-service-admin/src/auth-provider/ra-auth-jwt.ts +++ b/apps/hotel-management-service-admin/src/auth-provider/ra-auth-jwt.ts @@ -1,9 +1,6 @@ import { gql } from "@apollo/client/core"; import { AuthProvider } from "react-admin"; -import { - CREDENTIALS_LOCAL_STORAGE_ITEM, - USER_DATA_LOCAL_STORAGE_ITEM, -} from "../constants"; +// Note: We no longer persist credentials in localStorage. import { Credentials, LoginMutateResult } from "../types"; import { apolloClient } from "../data-provider/graphqlDataProvider"; @@ -26,47 +23,52 @@ export const jwtAuthProvider: AuthProvider = { }); if (userData && userData.data?.login.username) { - localStorage.setItem( - CREDENTIALS_LOCAL_STORAGE_ITEM, - createBearerAuthorizationHeader(userData.data.login?.accessToken) - ); - localStorage.setItem( - USER_DATA_LOCAL_STORAGE_ITEM, - JSON.stringify(userData.data) - ); + // Cookie is automatically set by the server; nothing to persist client-side return Promise.resolve(); } return Promise.reject(); }, - logout: () => { - localStorage.removeItem(CREDENTIALS_LOCAL_STORAGE_ITEM); - return Promise.resolve(); + logout: async () => { + // Invoke backend mutation to clear HttpOnly cookie + const LOGOUT = gql` + mutation logout { + logout + } + `; + try { + await apolloClient.mutate({ mutation: LOGOUT }); + // Apollo will include cookies automatically (credentials: include) + return Promise.resolve(); + } catch (e) { + return Promise.reject(e); + } }, checkError: ({ status }: any) => { if (status === 401 || status === 403) { - localStorage.removeItem(CREDENTIALS_LOCAL_STORAGE_ITEM); return Promise.reject(); } return Promise.resolve(); }, - checkAuth: () => { - return localStorage.getItem(CREDENTIALS_LOCAL_STORAGE_ITEM) - ? Promise.resolve() - : Promise.reject(); + checkAuth: async () => { + // Query backend `me` (lightweight) to confirm cookie validity + const ME = gql` + query me { + me { + id + } + } + `; + try { + await apolloClient.query({ query: ME, fetchPolicy: "network-only" }); + return Promise.resolve(); + } catch (error) { + // If request fails due to 401/403 or network error, treat as unauthenticated + return Promise.reject(error); + } }, getPermissions: () => Promise.reject("Unknown method"), getIdentity: () => { - const str = localStorage.getItem(USER_DATA_LOCAL_STORAGE_ITEM); - const userData: LoginMutateResult = JSON.parse(str || ""); - - return Promise.resolve({ - id: userData.login.username, - fullName: userData.login.username, - avatar: undefined, - }); + // TODO: Optionally fetch current user via a `me` query. For now, return empty. + return Promise.resolve({ id: undefined, fullName: undefined, avatar: undefined }); }, }; - -export function createBearerAuthorizationHeader(accessToken: string) { - return `Bearer ${accessToken}`; -} diff --git a/apps/hotel-management-service-admin/src/auth.ts b/apps/hotel-management-service-admin/src/auth.ts deleted file mode 100644 index 498b026..0000000 --- a/apps/hotel-management-service-admin/src/auth.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { EventEmitter } from "events"; -import { CREDENTIALS_LOCAL_STORAGE_ITEM } from "./constants"; -import { Credentials } from "./types"; - -const eventEmitter = new EventEmitter(); - -export function isAuthenticated(): boolean { - return Boolean(getCredentials()); -} - -export function listen(listener: (authenticated: boolean) => void): void { - eventEmitter.on("change", () => { - listener(isAuthenticated()); - }); -} - -export function setCredentials(credentials: Credentials) { - localStorage.setItem( - CREDENTIALS_LOCAL_STORAGE_ITEM, - JSON.stringify(credentials) - ); -} - -export function getCredentials(): Credentials | null { - const raw = localStorage.getItem(CREDENTIALS_LOCAL_STORAGE_ITEM); - if (raw === null) { - return null; - } - return JSON.parse(raw); -} - -export function removeCredentials(): void { - localStorage.removeItem(CREDENTIALS_LOCAL_STORAGE_ITEM); -} diff --git a/apps/hotel-management-service-admin/src/data-provider/graphqlDataProvider.ts b/apps/hotel-management-service-admin/src/data-provider/graphqlDataProvider.ts index 2deee26..f530062 100644 --- a/apps/hotel-management-service-admin/src/data-provider/graphqlDataProvider.ts +++ b/apps/hotel-management-service-admin/src/data-provider/graphqlDataProvider.ts @@ -1,26 +1,28 @@ import buildGraphQLProvider from "ra-data-graphql-amplication"; import { ApolloClient, InMemoryCache, createHttpLink } from "@apollo/client"; -import { setContext } from "@apollo/client/link/context"; -import { CREDENTIALS_LOCAL_STORAGE_ITEM } from "../constants"; +// We no longer need to manually inject an Authorization header; rely on cookies. const httpLink = createHttpLink({ uri: `${import.meta.env.VITE_REACT_APP_SERVER_URL}/graphql`, + credentials: "include", // send cookies with each request }); -// eslint-disable-next-line @typescript-eslint/naming-convention -const authLink = setContext((_, { headers }) => { - const token = localStorage.getItem(CREDENTIALS_LOCAL_STORAGE_ITEM); - return { - headers: { - ...headers, - authorization: token ? token : "", - }, - }; -}); export const apolloClient = new ApolloClient({ cache: new InMemoryCache(), - link: authLink.concat(httpLink), + link: httpLink, + // Always include credentials (cookies) in requests + defaultOptions: { + watchQuery: { + fetchPolicy: "cache-and-network", + }, + query: { + fetchPolicy: "network-only", + }, + mutate: { + fetchPolicy: "no-cache", + }, + }, }); export default buildGraphQLProvider({ diff --git a/apps/hotel-management-service-server/README.md b/apps/hotel-management-service-server/README.md index bca0445..b4b412d 100644 --- a/apps/hotel-management-service-server/README.md +++ b/apps/hotel-management-service-server/README.md @@ -24,7 +24,9 @@ Configuration for the server component can be provided through the use of enviro | DB_USER | the username used to connect to the database | [username] | | DB_PASSWORD | the password used to connect to the database | [password] | | DB_NAME | the name of the database | [service-name] / [project-name] | -| JWT_SECRET_KEY | the secret used to sign the json-web token | [secret] | +| JWT_SECRET | the secret used to sign the JSON Web Token | [secret] | +| CORS_ORIGIN | comma-separated list of allowed origins for CORS (e.g., http://localhost:3001) | http://localhost:3001 | +| NODE_ENV | set to `development` to allow non-secure cookies over HTTP | development | | JWT_EXPIRATION | the expiration time for the json-web token | 2d | > **Note** diff --git a/apps/hotel-management-service-server/package.json b/apps/hotel-management-service-server/package.json index 06b5482..06631e3 100644 --- a/apps/hotel-management-service-server/package.json +++ b/apps/hotel-management-service-server/package.json @@ -45,7 +45,10 @@ "reflect-metadata": "0.1.13", "ts-node": "10.9.2", "type-fest": "2.19.0", - "validator": "13.11.0" + "validator": "13.11.0", + "cookie-parser": "^1.4.6", + "@nestjs/helmet": "^11.0.1", + "helmet": "^7.0.0" }, "devDependencies": { "@nestjs/cli": "^10.1.18", diff --git a/apps/hotel-management-service-server/src/app.module.ts b/apps/hotel-management-service-server/src/app.module.ts index da8499c..5dfa7d2 100644 --- a/apps/hotel-management-service-server/src/app.module.ts +++ b/apps/hotel-management-service-server/src/app.module.ts @@ -4,6 +4,7 @@ import { RoomModule } from "./room/room.module"; import { ReservationModule } from "./reservation/reservation.module"; import { CustomerModule } from "./customer/customer.module"; import { HealthModule } from "./health/health.module"; +import { AuthModule } from "./auth/auth.module"; import { PrismaModule } from "./prisma/prisma.module"; import { SecretsManagerModule } from "./providers/secrets/secretsManager.module"; import { ServeStaticModule } from "@nestjs/serve-static"; @@ -15,6 +16,7 @@ import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo"; @Module({ controllers: [], imports: [ + AuthModule, HotelModule, RoomModule, ReservationModule, diff --git a/apps/hotel-management-service-server/src/auth/auth.module.ts b/apps/hotel-management-service-server/src/auth/auth.module.ts new file mode 100644 index 0000000..8395dc8 --- /dev/null +++ b/apps/hotel-management-service-server/src/auth/auth.module.ts @@ -0,0 +1,27 @@ +import { Module } from "@nestjs/common"; +import { JwtModule } from "@nestjs/jwt"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { AuthService } from "./auth.service"; +import { AuthResolver } from "./auth.resolver"; + +@Module({ + imports: [ + ConfigModule, + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (config: ConfigService) => ({ + secret: config.get("JWT_SECRET", "change-me"), + signOptions: { + expiresIn: config.get( + "JWT_EXPIRES_IN", + "24h" + ), + }, + }), + inject: [ConfigService], + }), + ], + providers: [AuthService, AuthResolver], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/apps/hotel-management-service-server/src/auth/auth.resolver.ts b/apps/hotel-management-service-server/src/auth/auth.resolver.ts new file mode 100644 index 0000000..3bf8fd5 --- /dev/null +++ b/apps/hotel-management-service-server/src/auth/auth.resolver.ts @@ -0,0 +1,37 @@ +import { Resolver, Mutation, Args } from "@nestjs/graphql"; +import { CredentialsInput } from "./credentials.input"; +import { LoginResponse } from "./login-response.object"; +import { AuthService } from "./auth.service"; +import { Response } from "express"; +import { Res } from "@nestjs/common"; + +@Resolver() +export class AuthResolver { + constructor(private readonly authService: AuthService) {} + + @Mutation(() => LoginResponse) + async login( + @Args("credentials") credentials: CredentialsInput, + @Res({ passthrough: true }) res: Response + ): Promise { + const { username, password } = credentials; + const accessToken = await this.authService.validateUser(username, password).then(() => this.authService.login(username)); + + // Set HttpOnly cookie for access token + res.cookie("accessToken", accessToken, { + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV !== "development", + path: "/", + maxAge: Number(process.env.JWT_COOKIE_MAX_AGE ?? 1000 * 60 * 60 * 24), // default 1 day + }); + + return { accessToken }; + } + + @Mutation(() => Boolean) + async logout(@Res({ passthrough: true }) res: Response): Promise { + res.clearCookie("accessToken", { path: "/" }); + return true; + } +} diff --git a/apps/hotel-management-service-server/src/auth/auth.service.ts b/apps/hotel-management-service-server/src/auth/auth.service.ts new file mode 100644 index 0000000..347f93c --- /dev/null +++ b/apps/hotel-management-service-server/src/auth/auth.service.ts @@ -0,0 +1,26 @@ +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { JwtService } from "@nestjs/jwt"; +import * as bcrypt from "bcrypt"; +import { PrismaService } from "../prisma/prisma.service"; + +@Injectable() +export class AuthService { + constructor(private jwt: JwtService, private prisma: PrismaService) {} + + async validateUser(username: string, password: string) { + const user = await this.prisma.user.findUnique({ where: { username } }); + if (!user) { + throw new UnauthorizedException(); + } + const valid = await bcrypt.compare(password, user.password); + if (!valid) { + throw new UnauthorizedException(); + } + return user; + } + + async login(username: string) { + const payload = { sub: username }; + return this.jwt.sign(payload); + } +} diff --git a/apps/hotel-management-service-server/src/auth/credentials.input.ts b/apps/hotel-management-service-server/src/auth/credentials.input.ts new file mode 100644 index 0000000..e31f0f5 --- /dev/null +++ b/apps/hotel-management-service-server/src/auth/credentials.input.ts @@ -0,0 +1,10 @@ +import { Field, InputType } from "@nestjs/graphql"; + +@InputType() +export class CredentialsInput { + @Field() + username: string; + + @Field() + password: string; +} diff --git a/apps/hotel-management-service-server/src/auth/login-response.object.ts b/apps/hotel-management-service-server/src/auth/login-response.object.ts new file mode 100644 index 0000000..30ca0e2 --- /dev/null +++ b/apps/hotel-management-service-server/src/auth/login-response.object.ts @@ -0,0 +1,7 @@ +import { Field, ObjectType } from "@nestjs/graphql"; + +@ObjectType() +export class LoginResponse { + @Field() + accessToken: string; +} diff --git a/apps/hotel-management-service-server/src/main.ts b/apps/hotel-management-service-server/src/main.ts index 474eead..9914f22 100644 --- a/apps/hotel-management-service-server/src/main.ts +++ b/apps/hotel-management-service-server/src/main.ts @@ -13,7 +13,56 @@ import { const { PORT = 3000 } = process.env; async function main() { - const app = await NestFactory.create(AppModule, { cors: true }); + // Configure CORS to allow credentials and specific origins + const corsOrigins = process.env.CORS_ORIGIN + ? process.env.CORS_ORIGIN.split(",") + : true; + + const app = await NestFactory.create(AppModule, { + cors: { + origin: corsOrigins, + credentials: true, + }, + }); + + // Enable security headers via helmet including a strict Content Security Policy + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const helmet = (await import("helmet")).default; + app.use( + helmet({ + // Disable the default CSP so we can configure our own below + contentSecurityPolicy: false, + }) + ); + // Apply a minimal CSP allowing resources only from self + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const { contentSecurityPolicy } = await import("helmet"); + app.use( + contentSecurityPolicy({ + directives: { + defaultSrc: ["'self'"], + objectSrc: ["'none'"], + baseUri: ["'self'"], + scriptSrc: ["'self'"] + }, + }) + ); + + // Enable cookie parsing for inbound requests + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const cookieParser = (await import("cookie-parser")).default; + app.use(cookieParser()); + + // Attach Authorization header from accessToken cookie when header is missing + app.use((req, _res, next) => { + if (!req.headers["authorization"] && req.cookies?.accessToken) { + req.headers["authorization"] = `Bearer ${req.cookies.accessToken}`; + } + next(); + }); app.setGlobalPrefix("api"); app.useGlobalPipes( diff --git a/apps/hotel-management-service-server/test/auth.e2e-spec.ts b/apps/hotel-management-service-server/test/auth.e2e-spec.ts new file mode 100644 index 0000000..8213736 --- /dev/null +++ b/apps/hotel-management-service-server/test/auth.e2e-spec.ts @@ -0,0 +1,27 @@ +import request from "supertest"; +import main from "../src/main"; + +let app: any; + +describe("Auth cookie flow", () => { + beforeAll(async () => { + app = await main; + }); + + it("login mutation should set HttpOnly accessToken cookie", async () => { + // seed test user credentials or assume test db has user `admin` / `password` hashed properly. + const query = { + query: `mutation Login($username: String!, $password: String!) {\n login(credentials: { username: $username, password: $password }) {\n accessToken\n }\n }`, + variables: { username: "admin", password: "password" }, + }; + + const res = await request(app.getHttpServer()) + .post("/graphql") + .send(query) + .expect(200); + + expect(res.headers["set-cookie"]).toBeDefined(); + const cookie = res.headers["set-cookie"].find((c: string) => c.startsWith("accessToken")); + expect(cookie).toBeDefined(); + }); +});