diff --git a/README.md b/README.md index ebdc54cc..f908bc7f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ https://www.figma.com/design/Kskaw5xH0D8rsljazkXmFL/Muse-Project?node-id=0-1&t=v 🛠Tech Stack: Kotlin, Spring Boot, Gradle, PostgreSQL -🔄DevOps: Docker, Docker Compose, Testcontainers, GitHub CI/CD (Self-Hosted Runners), GitGuardian +🔄DevOps: Docker, Docker Compose, TestcЩontainers, GitHub CI/CD (Self-Hosted Runners), GitGuardian 🏛️Arch: Clean Architecture, DDD, Feature Slicing, REST, TDD, Service Based, ArchUnit, Monorepository diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx index 3b946ff7..c4ca7280 100644 --- a/client/src/app/App.tsx +++ b/client/src/app/App.tsx @@ -7,7 +7,7 @@ import { RouterProvider, } from "react-router-dom"; import { HomePage } from "pages/home"; -import { UserProfilePage, loader as profileLoader } from "pages/profile"; +import { ProfilePage, loader as profileLoader } from "pages/profile"; import { CataloguePage, loader as catalogueLoader } from "pages/catalogue"; import { action as loginAction, @@ -49,11 +49,7 @@ import { FavoritePage, loader as favoriteLoader } from "pages/favorite"; const routes = createRoutesFromElements( } /> - } - loader={profileLoader} - /> + } loader={profileLoader} /> } diff --git a/client/src/domain/model/jwt.ts b/client/src/domain/model/jwt.ts index 762cccb0..62aa26a5 100644 --- a/client/src/domain/model/jwt.ts +++ b/client/src/domain/model/jwt.ts @@ -1,6 +1,7 @@ import { jwtDecode } from "jwt-decode"; import { Role } from "./role"; -import { Cookies } from "typescript-cookie"; +import { COOKIE_JWT_KEY } from "shared/config/frontend"; +import { deleteCookie, getCookie } from "shared/cookie/cookie"; interface JwtPayload { sub: string; @@ -10,8 +11,6 @@ interface JwtPayload { } export class Jwt { - public static readonly COOKIE_JWT_KEY = "jwt"; - value: string; private constructor(value: string) { @@ -36,7 +35,7 @@ export class Jwt { } public static extractFromCookie(): Jwt | null { - const jwtCookieRaw = Cookies.get(Jwt.COOKIE_JWT_KEY) as string | undefined; + const jwtCookieRaw = getCookie(COOKIE_JWT_KEY); if (jwtCookieRaw === undefined) { return null; } @@ -44,11 +43,11 @@ export class Jwt { } public static eraseFromCookie() { - Cookies.remove(Jwt.COOKIE_JWT_KEY); + deleteCookie(COOKIE_JWT_KEY); } public static extractFromLocalStorage(): Jwt | null { - const jwtRaw = window.localStorage.getItem(Jwt.COOKIE_JWT_KEY); + const jwtRaw = window.localStorage.getItem(COOKIE_JWT_KEY); if (jwtRaw === null) { return null; } @@ -56,11 +55,11 @@ export class Jwt { } public static putToLocalStorage(jwtRaw: string) { - window.localStorage.setItem(Jwt.COOKIE_JWT_KEY, jwtRaw); + window.localStorage.setItem(COOKIE_JWT_KEY, jwtRaw); } public static eraseFromLocalStorage() { - window.localStorage.removeItem(Jwt.COOKIE_JWT_KEY); + window.localStorage.removeItem(COOKIE_JWT_KEY); } } diff --git a/client/src/generated/.openapi-generator/FILES b/client/src/generated/.openapi-generator/FILES index 95c13604..f6f12d3d 100644 --- a/client/src/generated/.openapi-generator/FILES +++ b/client/src/generated/.openapi-generator/FILES @@ -16,6 +16,7 @@ api/get-instruments-by-criteria-paginated-api.ts api/get-manufacturers-api.ts api/get-user-profile-api.ts api/list-favorite-api.ts +api/logout-api.ts api/remove-favorite-api.ts api/user-registration-api.ts base.ts diff --git a/client/src/generated/api.ts b/client/src/generated/api.ts index 0e3cabc1..a7e279d7 100644 --- a/client/src/generated/api.ts +++ b/client/src/generated/api.ts @@ -27,5 +27,6 @@ export * from "./api/get-instruments-by-criteria-paginated-api"; export * from "./api/get-manufacturers-api"; export * from "./api/get-user-profile-api"; export * from "./api/list-favorite-api"; +export * from "./api/logout-api"; export * from "./api/remove-favorite-api"; export * from "./api/user-registration-api"; diff --git a/client/src/generated/api/logout-api.ts b/client/src/generated/api/logout-api.ts new file mode 100644 index 00000000..1c6c45d1 --- /dev/null +++ b/client/src/generated/api/logout-api.ts @@ -0,0 +1,171 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Country + * Basic Material + * + * The version of the OpenAPI document: 1.0.0 + * Contact: baskirill.an@gmail.com + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import type { Configuration } from "../configuration"; +import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from "axios"; +import globalAxios from "axios"; +// Some imports not used depending on template conditions +// @ts-ignore +import { + DUMMY_BASE_URL, + assertParamExists, + setApiKeyToObject, + setBasicAuthToObject, + setBearerAuthToObject, + setOAuthToObject, + setSearchParams, + serializeDataIfNeeded, + toPathString, + createRequestFunction, +} from "../common"; +// @ts-ignore +import { + BASE_PATH, + COLLECTION_FORMATS, + type RequestArgs, + BaseAPI, + RequiredError, + operationServerMap, +} from "../base"; +// @ts-ignore +import type { ServerError } from "../model"; +/** + * LogoutApi - axios parameter creator + * @export + */ +export const LogoutApiAxiosParamCreator = function ( + configuration?: Configuration, +) { + return { + /** + * Logout Endpoint + * @summary Logout Endpoint + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + logout: async ( + options: RawAxiosRequestConfig = {}, + ): Promise => { + const localVarPath = `/api/auth/logout`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "POST", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + }; +}; + +/** + * LogoutApi - functional programming interface + * @export + */ +export const LogoutApiFp = function (configuration?: Configuration) { + const localVarAxiosParamCreator = LogoutApiAxiosParamCreator(configuration); + return { + /** + * Logout Endpoint + * @summary Logout Endpoint + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async logout( + options?: RawAxiosRequestConfig, + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.logout(options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = + operationServerMap["LogoutApi.logout"]?.[localVarOperationServerIndex] + ?.url; + return (axios, basePath) => + createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration, + )(axios, localVarOperationServerBasePath || basePath); + }, + }; +}; + +/** + * LogoutApi - factory interface + * @export + */ +export const LogoutApiFactory = function ( + configuration?: Configuration, + basePath?: string, + axios?: AxiosInstance, +) { + const localVarFp = LogoutApiFp(configuration); + return { + /** + * Logout Endpoint + * @summary Logout Endpoint + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + logout(options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp + .logout(options) + .then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * LogoutApi - object-oriented interface + * @export + * @class LogoutApi + * @extends {BaseAPI} + */ +export class LogoutApi extends BaseAPI { + /** + * Logout Endpoint + * @summary Logout Endpoint + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof LogoutApi + */ + public logout(options?: RawAxiosRequestConfig) { + return LogoutApiFp(this.configuration) + .logout(options) + .then((request) => request(this.axios, this.basePath)); + } +} diff --git a/client/src/generated/model/instrument-detail-without-id.ts b/client/src/generated/model/instrument-detail-without-id.ts new file mode 100644 index 00000000..673af7bc --- /dev/null +++ b/client/src/generated/model/instrument-detail-without-id.ts @@ -0,0 +1,85 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Country + * Basic Material + * + * The version of the OpenAPI document: 1.0.0 + * Contact: baskirill.an@gmail.com + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +// May contain unused imports in some cases +// @ts-ignore +import type { BasicMaterial } from "./basic-material"; +// May contain unused imports in some cases +// @ts-ignore +import type { Country } from "./country"; +// May contain unused imports in some cases +// @ts-ignore +import type { InstrumentName } from "./instrument-name"; +// May contain unused imports in some cases +// @ts-ignore +import type { InstrumentType } from "./instrument-type"; +// May contain unused imports in some cases +// @ts-ignore +import type { ManufactureDate } from "./manufacture-date"; +// May contain unused imports in some cases +// @ts-ignore +import type { ManufacturerName } from "./manufacturer-name"; +// May contain unused imports in some cases +// @ts-ignore +import type { ReleaseDate } from "./release-date"; + +/** + * + * @export + * @interface InstrumentDetailWithoutId + */ +export interface InstrumentDetailWithoutId { + /** + * + * @type {InstrumentName} + * @memberof InstrumentDetailWithoutId + */ + instrument_name: InstrumentName; + /** + * + * @type {InstrumentType} + * @memberof InstrumentDetailWithoutId + */ + instrument_type: InstrumentType; + /** + * + * @type {ManufacturerName} + * @memberof InstrumentDetailWithoutId + */ + manufacturer_name: ManufacturerName; + /** + * + * @type {ManufactureDate} + * @memberof InstrumentDetailWithoutId + */ + manufacturer_date: ManufactureDate; + /** + * + * @type {ReleaseDate} + * @memberof InstrumentDetailWithoutId + */ + release_date: ReleaseDate; + /** + * + * @type {Country} + * @memberof InstrumentDetailWithoutId + */ + country: Country; + /** + * + * @type {Array} + * @memberof InstrumentDetailWithoutId + */ + basic_materials: Array; +} diff --git a/client/src/generated/model/instrument-photo.ts b/client/src/generated/model/instrument-photo.ts new file mode 100644 index 00000000..d90176c5 --- /dev/null +++ b/client/src/generated/model/instrument-photo.ts @@ -0,0 +1,27 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Country + * Basic Material + * + * The version of the OpenAPI document: 1.0.0 + * Contact: baskirill.an@gmail.com + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + * @interface InstrumentPhoto + */ +export interface InstrumentPhoto { + /** + * + * @type {string} + * @memberof InstrumentPhoto + */ + photo: string; +} diff --git a/client/src/pages/home/ui/Home.page.tsx b/client/src/pages/home/ui/Home.page.tsx index ec378be0..326a4829 100644 --- a/client/src/pages/home/ui/Home.page.tsx +++ b/client/src/pages/home/ui/Home.page.tsx @@ -63,7 +63,9 @@ export function HomePage() { -

Why Choose Us for Your Musical Needs

+

+ Why Choose Us for Your Musical Needs +

@@ -73,9 +75,9 @@ export function HomePage() {
Enjoy competitive prices and exclusive deals on top brands
-

Trending Instruments

+

Trending Instruments

-
+
=> { +}): Promise => { const { login, password, errors } = parseLoginForm(await request.formData()); if (errors.length !== 0) { @@ -30,6 +31,10 @@ export const action: ActionFunction = async ({ }, ); + if (response.status === 200) { + return redirect(PROFILE); + } + if (response.status !== 200) { return { errors: ["Failed to authenticate"], diff --git a/client/src/pages/login/ui/Login.page.tsx b/client/src/pages/login/ui/Login.page.tsx index 80bd1374..4b9dd302 100644 --- a/client/src/pages/login/ui/Login.page.tsx +++ b/client/src/pages/login/ui/Login.page.tsx @@ -3,11 +3,11 @@ import styles from "./styles/Login.page.module.css"; import { HeaderWidget } from "widgets/header"; import { FooterWidget } from "widgets/footer"; import { Form, useActionData, useNavigate } from "react-router-dom"; -import { LogInAction } from "../api/action"; +import { LoginAction } from "../api/action"; import { REGISTRATION_URL } from "shared/config/paths"; export function LoginPage() { - const actionData = useActionData() as LogInAction; + const actionData = useActionData() as LoginAction; const navigate = useNavigate(); const handleRegisterRedirect = () => { @@ -18,10 +18,6 @@ export function LoginPage() { <> - {actionData?.errors.length === 0 && ( -
✅ Welcome!
- )} -
diff --git a/client/src/pages/login/ui/styles/Login.page.module.css b/client/src/pages/login/ui/styles/Login.page.module.css index 68f3766a..7edf8522 100644 --- a/client/src/pages/login/ui/styles/Login.page.module.css +++ b/client/src/pages/login/ui/styles/Login.page.module.css @@ -3,22 +3,9 @@ display: flex; flex-direction: column; - margin: 50px auto; + margin: 2em auto; input { - font-size: 32px; + font-size: 2em; } } - -.login__ok { - background-color: green; - font-size: 32px; - padding: 0 10px; - max-width: 640px; - margin: 0 auto; -} - -.login__error { - background-color: red; - padding: 20px 10px; -} diff --git a/client/src/pages/profile/index.ts b/client/src/pages/profile/index.ts index 36d73635..3e210273 100644 --- a/client/src/pages/profile/index.ts +++ b/client/src/pages/profile/index.ts @@ -1,4 +1,4 @@ -import { UserProfilePage } from "./ui/UserProfile.page"; +import { ProfilePage } from "./ui/Profile.page"; import { loader } from "./api/loader"; -export { UserProfilePage, loader }; +export { ProfilePage, loader }; diff --git a/client/src/pages/profile/ui/Profile.page.tsx b/client/src/pages/profile/ui/Profile.page.tsx new file mode 100644 index 00000000..3edf14fc --- /dev/null +++ b/client/src/pages/profile/ui/Profile.page.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import styles from "./styles/Profile.page.module.css"; +import { HeaderWidget } from "widgets/header"; +import { FooterWidget } from "widgets/footer"; +import { useLoaderData, useNavigate } from "react-router-dom"; +import { ProfileDetails } from "generated/model"; +import { useJwt } from "shared/jwt/use-jwt"; +import { LogoutApi } from "generated/api/logout-api"; +import Jwt from "domain/model/jwt"; +import { LOGIN } from "shared/config/paths"; + +const logout = new LogoutApi(); + +export function ProfilePage() { + useJwt(); + const navigate = useNavigate(); + const profile = useLoaderData() as ProfileDetails; + + const onLogoutHandler = () => { + const fetchLogout = async () => { + const response = await logout.logout({ + withCredentials: true, + headers: { + Authorization: `Bearer ${Jwt.extractFromCookie()?.toStringValue()}`, + }, + }); + + if (response.status === 200) { + navigate(LOGIN); + return; + } + + throw new Error("Fail to logout"); + }; + + fetchLogout(); + }; + + return ( + <> + + +
+

{profile.full_name}

+
+ Name: {profile?.full_name} +
+
+ Role: {profile?.role} +
+ +
+ + + + ); +} + +export default ProfilePage; diff --git a/client/src/pages/profile/ui/UserProfile.page.tsx b/client/src/pages/profile/ui/UserProfile.page.tsx deleted file mode 100644 index e7c29989..00000000 --- a/client/src/pages/profile/ui/UserProfile.page.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from "react"; -import "./styles/UserProfile.page.css"; -import { HeaderWidget } from "widgets/header"; -import { FooterWidget } from "widgets/footer"; -import { useLoaderData } from "react-router-dom"; -import { ProfileDetails } from "generated/model"; - -export function UserProfilePage() { - const profile = useLoaderData() as ProfileDetails; - - return ( - <> - -
-

{profile?.full_name}

-
- Name: {profile?.full_name} -
-
- Role: {profile?.role} -
-
- - - ); -} - -export default UserProfilePage; diff --git a/client/src/pages/profile/ui/styles/Profile.page.module.css b/client/src/pages/profile/ui/styles/Profile.page.module.css new file mode 100644 index 00000000..a6eeb53d --- /dev/null +++ b/client/src/pages/profile/ui/styles/Profile.page.module.css @@ -0,0 +1,10 @@ +.profile { + display: flex; + flex-direction: column; + max-width: 640px; + margin: 2em auto; +} + +.logout__button { + background-color: #ff0000; +} diff --git a/client/src/pages/profile/ui/styles/UserProfile.page.css b/client/src/pages/profile/ui/styles/UserProfile.page.css deleted file mode 100644 index c5da081e..00000000 --- a/client/src/pages/profile/ui/styles/UserProfile.page.css +++ /dev/null @@ -1,3 +0,0 @@ -#profile { - font-size: 20px; -} diff --git a/client/src/shared/config/frontend.ts b/client/src/shared/config/frontend.ts index b618aac3..8994a21c 100644 --- a/client/src/shared/config/frontend.ts +++ b/client/src/shared/config/frontend.ts @@ -1,3 +1,5 @@ export const CATALOGUE_DEFAULT_PAGE_SIZE = 4; export const CATALOGUE_DEFAULT_PAGE_NUMBER = 1; export const MINIMAL_PASSWORD_LENGTH = 2; + +export const COOKIE_JWT_KEY = "jwt"; diff --git a/client/src/shared/cookie/cookie.ts b/client/src/shared/cookie/cookie.ts new file mode 100644 index 00000000..22eb35f1 --- /dev/null +++ b/client/src/shared/cookie/cookie.ts @@ -0,0 +1,35 @@ +// https://gist.github.com/joduplessis/7b3b4340353760e945f972a69e855d11 + +/* + * General utils for managing cookies in Typescript. + */ +export function setCookie(name: string, val: string) { + const date = new Date(); + const value = val; + + // Set it expire in 7 days + date.setTime(date.getTime() + 7 * 24 * 60 * 60 * 1000); + + // Set it + document.cookie = + name + "=" + value + "; expires=" + date.toUTCString() + "; path=/"; +} + +export function getCookie(name: string) { + const value = "; " + document.cookie; + const parts = value.split("; " + name + "="); + + if (parts.length == 2) { + return parts.pop()?.split(";").shift(); + } +} + +export function deleteCookie(name: string) { + const date = new Date(); + + // Set it expire in -1 days + date.setTime(date.getTime() + -1 * 24 * 60 * 60 * 1000); + + // Set it + document.cookie = name + "=; expires=" + date.toUTCString() + "; path=/"; +} diff --git a/client/src/shared/cookie/get-cookie.ts b/client/src/shared/cookie/get-cookie.ts deleted file mode 100644 index f3699c47..00000000 --- a/client/src/shared/cookie/get-cookie.ts +++ /dev/null @@ -1,15 +0,0 @@ -// https://gist.github.com/hunan-rostomyan/28e8702c1cecff41f7fe64345b76f2ca -export function getCookie(name: string): string | null { - const nameLenPlus = name.length + 1; - return ( - document.cookie - .split(";") - .map((c) => c.trim()) - .filter((cookie) => { - return cookie.substring(0, nameLenPlus) === `${name}=`; - }) - .map((cookie) => { - return decodeURIComponent(cookie.substring(nameLenPlus)); - })[0] || null - ); -} diff --git a/client/src/shared/index.ts b/client/src/shared/index.ts index 76eefd34..d21bece6 100644 --- a/client/src/shared/index.ts +++ b/client/src/shared/index.ts @@ -1,5 +1,4 @@ import { InstrumentCard } from "shared/instrument-card"; -import { getCookie } from "shared/cookie/get-cookie"; import { useJwt } from "./jwt/use-jwt"; import { @@ -11,6 +10,5 @@ export { InstrumentCard, CATALOGUE_DEFAULT_PAGE_SIZE, CATALOGUE_DEFAULT_PAGE_NUMBER, - getCookie, useJwt, }; diff --git a/client/src/shared/instrument-card/ui/InstrumentActions.tsx b/client/src/shared/instrument-card/ui/InstrumentActions.tsx index f317bc38..2ac0a80a 100644 --- a/client/src/shared/instrument-card/ui/InstrumentActions.tsx +++ b/client/src/shared/instrument-card/ui/InstrumentActions.tsx @@ -3,7 +3,6 @@ import Jwt from "domain/model/jwt"; import { ModalWidget } from "widgets/modal"; import styles from "./styles/InstrumentActions.module.css"; import { Role } from "domain/model/role"; -import { Cookies } from "typescript-cookie"; import { InstrumentDetail } from "generated/model"; import { FavoriteButton, @@ -11,6 +10,8 @@ import { GoToInstrumentButton, RemoveInstrumentButton, } from "shared/instrument-card-actions"; +import { COOKIE_JWT_KEY } from "shared/config/frontend"; +import { getCookie } from "shared/cookie/cookie"; interface Props { instrument: InstrumentDetail; @@ -20,9 +21,7 @@ interface Props { export const InstrumentActions = (props: Props) => { const [errorModal, setErrorModal] = useState(false); const [successModal, setSuccessModal] = useState(false); - const jwt = useRef( - Cookies.get("jwt") as string | undefined, - ); + const jwt = useRef(getCookie(COOKIE_JWT_KEY)); return (
diff --git a/client/src/widgets/header/ui/Header.widget.tsx b/client/src/widgets/header/ui/Header.widget.tsx index b273bcc6..fe3b4ab7 100644 --- a/client/src/widgets/header/ui/Header.widget.tsx +++ b/client/src/widgets/header/ui/Header.widget.tsx @@ -2,15 +2,16 @@ import React, { useRef } from "react"; import "./styles/HeaderWidget.css"; import { useNavigate } from "react-router-dom"; import { CATALOGUE, FAVORITE, HOME, LOGIN, PROFILE } from "shared/config/paths"; -import { Cookies } from "typescript-cookie"; import { Jwt } from "domain/model/jwt"; import { Role } from "domain/model/role"; +import { getCookie } from "shared/cookie/cookie"; +import { COOKIE_JWT_KEY } from "shared/config/frontend"; export function HeaderWidget() { - const jwt = useRef(undefined); + const jwt = useRef(getCookie(COOKIE_JWT_KEY)); if (typeof document !== "undefined") { - jwt.current = Cookies.get("jwt") as string | undefined; + jwt.current = getCookie(COOKIE_JWT_KEY); } const navigate = useNavigate(); diff --git a/openapi/openapi.yml b/openapi/openapi.yml index 08ee1963..b3decf5d 100644 --- a/openapi/openapi.yml +++ b/openapi/openapi.yml @@ -58,6 +58,9 @@ tags: - name: basicLogin description: Everything about user x-displayName: basicLogin + - name: logout + description: Logout + x-displayName: logout - name: getManufacturers description: Get Manufacturers x-displayName: getManufacturers @@ -489,6 +492,26 @@ paths: application/json: schema: $ref: '#/components/schemas/ServerError' + /api/auth/logout: + post: + description: Logout Endpoint + summary: Logout Endpoint + operationId: logout + tags: + - logout + responses: + '200': + description: Profile Details + content: + application/json: + schema: + type: object + default: + description: server error + content: + application/json: + schema: + $ref: '#/components/schemas/ServerError' /api/manufacturer: get: description: Get Manufacturers @@ -973,6 +996,9 @@ x-tagGroups: - name: Basic Login tags: - basicLogin + - name: Logout Endpoint + tags: + - logout - name: Get Manufacturers tags: - getManufacturers diff --git a/openapi/specs/login/BasicLoginEndpoint.yml b/openapi/specs/login/LoginEndpoint.yml similarity index 100% rename from openapi/specs/login/BasicLoginEndpoint.yml rename to openapi/specs/login/LoginEndpoint.yml diff --git a/openapi/specs/logout/LogoutEndpoint.yml b/openapi/specs/logout/LogoutEndpoint.yml new file mode 100644 index 00000000..a759439b --- /dev/null +++ b/openapi/specs/logout/LogoutEndpoint.yml @@ -0,0 +1,44 @@ +openapi: "3.0.0" # openApiGenerate gradle task do not support officially 3.1.0 openapi version + +info: + description: Logout Endpoint + version: 1.0.0 + title: Logout Endpoint + contact: + name: Kirill B + email: baskirill.an@gmail.com +servers: + - url: http://localhost:{port}/{basePath} + description: Local server (uses local data) + variables: + port: + default: "8080" + enum: + - "8080" + - "8000" + +tags: + - name: logout + description: Logout + +paths: + /api/auth/logout: + post: + description: Logout Endpoint + summary: Logout Endpoint + operationId: logout + tags: + - logout + responses: + "200": + description: Profile Details + content: + application/json: + schema: + type: object + default: + description: server error + content: + application/json: + schema: + $ref: "./../common/ServerError.yml#/components/schemas/ServerError" diff --git a/server/app/build/openapi/src/main/kotlin/mu/muse/rest/api/LogoutApi.kt b/server/app/build/openapi/src/main/kotlin/mu/muse/rest/api/LogoutApi.kt new file mode 100644 index 00000000..4a9ce587 --- /dev/null +++ b/server/app/build/openapi/src/main/kotlin/mu/muse/rest/api/LogoutApi.kt @@ -0,0 +1,42 @@ +/** + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.8.0). + * https://openapi-generator.tech + * Do not edit the class manually. +*/ +package mu.muse.rest.api + +import mu.muse.rest.dto.ServerError +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity + +import org.springframework.web.bind.annotation.* +import org.springframework.validation.annotation.Validated +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.beans.factory.annotation.Autowired + +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import jakarta.validation.Valid + +import kotlin.collections.List +import kotlin.collections.Map + +@RestController +@Validated +interface LogoutApi { + + + @RequestMapping( + method = [RequestMethod.POST], + value = ["/api/auth/logout"], + produces = ["application/json"] + ) + fun logout(): ResponseEntity +} diff --git a/server/app/src/main/kotlin/mu/muse/application/muse/RestConfiguration.kt b/server/app/src/main/kotlin/mu/muse/application/muse/RestConfiguration.kt index 9e09f467..1f0e7e7d 100644 --- a/server/app/src/main/kotlin/mu/muse/application/muse/RestConfiguration.kt +++ b/server/app/src/main/kotlin/mu/muse/application/muse/RestConfiguration.kt @@ -16,6 +16,7 @@ import mu.muse.rest.instruments.GetInstrumentTypesEndpoint import mu.muse.rest.instruments.GetInstrumentsByCriteriaEndpoint import mu.muse.rest.instruments.GetInstrumentsByCriteriaPaginatedEndpoint import mu.muse.rest.login.BasicLoginEndpoint +import mu.muse.rest.logout.LogoutEndpoint import mu.muse.rest.profile.GetProfileEndpoint import mu.muse.rest.registration.RegistrationEndpoint import mu.muse.usecase.BasicLogin @@ -102,4 +103,7 @@ class RestConfiguration { @Bean fun getInstrumentPhotoEndpoint(getInstrumentPhoto: GetInstrumentPhoto) = GetInstrumentPhotoEndpoint(getInstrumentPhoto) + + @Bean + fun logoutEndpoint() = LogoutEndpoint() } diff --git a/server/app/src/main/kotlin/mu/muse/application/muse/SecurityConfiguration.kt b/server/app/src/main/kotlin/mu/muse/application/muse/SecurityConfiguration.kt index bfdb827c..d0297560 100644 --- a/server/app/src/main/kotlin/mu/muse/application/muse/SecurityConfiguration.kt +++ b/server/app/src/main/kotlin/mu/muse/application/muse/SecurityConfiguration.kt @@ -16,7 +16,7 @@ import mu.muse.rest.API_INSTRUMENT_PHOTO import mu.muse.rest.API_INSTRUMENT_TYPES import mu.muse.rest.API_MANUFACTURERS import mu.muse.rest.API_REGISTRATION -import mu.muse.rest.AUTH_BASIC_LOGIN +import mu.muse.rest.API_LOGIN import mu.muse.usecase.access.user.UserExtractor import mu.muse.usecase.scenario.login.BasicLoginUseCase import org.springframework.beans.factory.annotation.Value @@ -95,7 +95,7 @@ class SecurityConfiguration { http = http.authorizeHttpRequests { request -> request - .requestMatchers(AUTH_BASIC_LOGIN).permitAll() + .requestMatchers(API_LOGIN).permitAll() .requestMatchers(HttpMethod.POST, API_INSTRUMENTS).permitAll() .requestMatchers(HttpMethod.POST, API_INSTRUMENTS_PAGINATED).permitAll() .requestMatchers(HttpMethod.GET, API_INSTRUMENT_BY_ID).permitAll() diff --git a/server/app/src/main/kotlin/mu/muse/rest/Constants.kt b/server/app/src/main/kotlin/mu/muse/rest/Constants.kt index 59bde844..4e19a4b7 100644 --- a/server/app/src/main/kotlin/mu/muse/rest/Constants.kt +++ b/server/app/src/main/kotlin/mu/muse/rest/Constants.kt @@ -1,3 +1,6 @@ package mu.muse.rest const val FAVORITE_INSTRUMENTS_SESSION_KEY = "FAVORITE_INSTRUMENTS" + +const val COOKIE_SESSION_ID = "SESSIONID" +const val COOKIE_JWT_KEY = "jwt" diff --git a/server/app/src/main/kotlin/mu/muse/rest/EndpointUrl.kt b/server/app/src/main/kotlin/mu/muse/rest/EndpointUrl.kt index ddaad4b2..b06d075c 100644 --- a/server/app/src/main/kotlin/mu/muse/rest/EndpointUrl.kt +++ b/server/app/src/main/kotlin/mu/muse/rest/EndpointUrl.kt @@ -1,8 +1,8 @@ package mu.muse.rest -const val AUTH_BASIC_LOGIN = "/api/auth/login" - const val API = "/api" +const val API_LOGIN = "$API/auth/login" +const val API_LOGOUT = "$API/auth/logout" const val API_INSTRUMENTS = "$API/instruments" const val API_INSTRUMENTS_PAGINATED = "$API/instruments/paginated" const val API_INSTRUMENT_BY_ID = "$API/instrument/{id:\\d+}" diff --git a/server/app/src/main/kotlin/mu/muse/rest/login/BasicLoginEndpoint.kt b/server/app/src/main/kotlin/mu/muse/rest/login/BasicLoginEndpoint.kt index f19fea53..79a08309 100644 --- a/server/app/src/main/kotlin/mu/muse/rest/login/BasicLoginEndpoint.kt +++ b/server/app/src/main/kotlin/mu/muse/rest/login/BasicLoginEndpoint.kt @@ -3,6 +3,7 @@ package mu.muse.rest.login import jakarta.servlet.http.Cookie import mu.muse.domain.user.Password import mu.muse.domain.user.Username +import mu.muse.rest.COOKIE_JWT_KEY import mu.muse.rest.api.BasicLoginApi import mu.muse.rest.dto.JwtResponse import mu.muse.rest.dto.UsernameAndPasswordRequestBody @@ -27,7 +28,7 @@ class BasicLoginEndpoint(private val basicLogin: BasicLogin) : BasicLoginApi { val id = Username.from(usernameAndPasswordRequestBody.username) val password = Password.from(usernameAndPasswordRequestBody.password) val jwtRaw = basicLogin.execute(id, password) - val cookie = Cookie("jwt", jwtRaw) + val cookie = Cookie(COOKIE_JWT_KEY, jwtRaw) cookie.isHttpOnly = false // because we need to extract a role from token at client side cookie.maxAge = COOKIE_MAX_AGE_SEVEN_DAYS_IN_SECONDS cookie.path = COOKIE_PATH diff --git a/server/app/src/main/kotlin/mu/muse/rest/logout/LogoutEndpoint.kt b/server/app/src/main/kotlin/mu/muse/rest/logout/LogoutEndpoint.kt new file mode 100644 index 00000000..10cde28f --- /dev/null +++ b/server/app/src/main/kotlin/mu/muse/rest/logout/LogoutEndpoint.kt @@ -0,0 +1,36 @@ +package mu.muse.rest.logout + +import jakarta.annotation.security.RolesAllowed +import jakarta.servlet.http.Cookie +import mu.muse.domain.user.Role +import mu.muse.rest.COOKIE_JWT_KEY +import mu.muse.rest.COOKIE_SESSION_ID +import mu.muse.rest.api.LogoutApi +import mu.muse.rest.login.BasicLoginEndpoint.Companion.COOKIE_PATH +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.context.request.ServletRequestAttributes + +@RestController +class LogoutEndpoint : LogoutApi { + + @RolesAllowed(Role.USER, Role.EDITOR) + override fun logout(): ResponseEntity { + val request = (RequestContextHolder.getRequestAttributes() as ServletRequestAttributes).request + + val jwtKeyCookie = Cookie(COOKIE_JWT_KEY, "") + jwtKeyCookie.maxAge = 0 + jwtKeyCookie.path = COOKIE_PATH + (RequestContextHolder.getRequestAttributes() as ServletRequestAttributes).response?.addCookie(jwtKeyCookie) + + request.session.invalidate() + + val sessionIdCookie = Cookie(COOKIE_SESSION_ID, "") + sessionIdCookie.maxAge = 0 + sessionIdCookie.path = COOKIE_PATH + (RequestContextHolder.getRequestAttributes() as ServletRequestAttributes).response?.addCookie(sessionIdCookie) + + return ResponseEntity.ok().build() + } +}