Skip to content

Commit

Permalink
feat(logout): client and server side
Browse files Browse the repository at this point in the history
  • Loading branch information
bas-kirill committed Aug 25, 2024
1 parent e0e4230 commit 4ce890f
Show file tree
Hide file tree
Showing 16 changed files with 141 additions and 49 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 7 additions & 8 deletions client/src/domain/model/jwt.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -10,8 +11,6 @@ interface JwtPayload {
}

export class Jwt {
public static readonly COOKIE_JWT_KEY = "jwt";

value: string;

private constructor(value: string) {
Expand All @@ -36,31 +35,31 @@ 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;
}
return Jwt.from(jwtCookieRaw);
}

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;
}
return Jwt.from(jwtRaw);
}

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);
}
}

Expand Down
13 changes: 9 additions & 4 deletions client/src/pages/login/api/action.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { ActionFunction } from "react-router-dom";
import { ActionFunction, redirect } from "react-router-dom";
import { parseLoginForm } from "./../model/parse-login-form";
import { BasicLoginApi } from "generated/api/basic-login-api";
import { BasicLoginApi } from "generated/api";
import { PROFILE } from "shared/config/paths";

export interface LogInAction {
export interface LoginAction {
errors: string[];
}

const basicLoginApi = new BasicLoginApi();

export const action: ActionFunction = async ({
request,
}): Promise<LogInAction> => {
}): Promise<LoginAction | Response> => {
const { login, password, errors } = parseLoginForm(await request.formData());

if (errors.length !== 0) {
Expand All @@ -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"],
Expand Down
33 changes: 28 additions & 5 deletions client/src/pages/profile/ui/Profile.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,40 @@ import React from "react";
import styles from "./styles/Profile.page.module.css";
import { HeaderWidget } from "widgets/header";
import { FooterWidget } from "widgets/footer";
import { useLoaderData } from "react-router-dom";
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 (
<>
<HeaderWidget />
Expand All @@ -23,10 +49,7 @@ export function ProfilePage() {
<div>
<b>Role</b>: <span>{profile?.role}</span>
</div>
<button
onClick={e => e.target}
className={styles.logout__button}
>
<button onClick={onLogoutHandler} className={styles.logout__button}>
Logout
</button>
</div>
Expand Down
2 changes: 2 additions & 0 deletions client/src/shared/config/frontend.ts
Original file line number Diff line number Diff line change
@@ -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";
34 changes: 34 additions & 0 deletions client/src/shared/cookie/cookie.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// 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=/";
}
15 changes: 0 additions & 15 deletions client/src/shared/cookie/get-cookie.ts

This file was deleted.

2 changes: 0 additions & 2 deletions client/src/shared/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { InstrumentCard } from "shared/instrument-card";
import { getCookie } from "shared/cookie/get-cookie";
import { useJwt } from "./jwt/use-jwt";

import {
Expand All @@ -11,6 +10,5 @@ export {
InstrumentCard,
CATALOGUE_DEFAULT_PAGE_SIZE,
CATALOGUE_DEFAULT_PAGE_NUMBER,
getCookie,
useJwt,
};
5 changes: 3 additions & 2 deletions client/src/shared/instrument-card/ui/InstrumentActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ 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,
EditInstrumentButton,
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;
Expand All @@ -21,7 +22,7 @@ export const InstrumentActions = (props: Props) => {
const [errorModal, setErrorModal] = useState<boolean>(false);
const [successModal, setSuccessModal] = useState<boolean>(false);
const jwt = useRef<string | undefined>(
Cookies.get("jwt") as string | undefined,
getCookie(COOKIE_JWT_KEY)
);

return (
Expand Down
7 changes: 4 additions & 3 deletions client/src/widgets/header/ui/Header.widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined>(undefined);
const jwt = useRef<string | undefined>(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();
Expand Down
10 changes: 5 additions & 5 deletions openapi/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,9 @@ tags:
- name: basicLogin
description: Everything about user
x-displayName: basicLogin
- name: userLogout
- name: logout
description: Logout
x-displayName: userLogout
x-displayName: logout
- name: getManufacturers
description: Get Manufacturers
x-displayName: getManufacturers
Expand Down Expand Up @@ -496,9 +496,9 @@ paths:
post:
description: Logout Endpoint
summary: Logout Endpoint
operationId: userLogout
operationId: logout
tags:
- userLogout
- logout
responses:
'200':
description: Profile Details
Expand Down Expand Up @@ -998,7 +998,7 @@ x-tagGroups:
- basicLogin
- name: Logout Endpoint
tags:
- userLogout
- logout
- name: Get Manufacturers
tags:
- getManufacturers
Expand Down
6 changes: 3 additions & 3 deletions openapi/specs/logout/LogoutEndpoint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,17 @@ servers:
- "8000"

tags:
- name: userLogout
- name: logout
description: Logout

paths:
/api/auth/logout:
post:
description: Logout Endpoint
summary: Logout Endpoint
operationId: userLogout
operationId: logout
tags:
- userLogout
- logout
responses:
"200":
description: Profile Details
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -102,4 +103,7 @@ class RestConfiguration {
@Bean
fun getInstrumentPhotoEndpoint(getInstrumentPhoto: GetInstrumentPhoto) =
GetInstrumentPhotoEndpoint(getInstrumentPhoto)

@Bean
fun logoutEndpoint() = LogoutEndpoint()
}
3 changes: 3 additions & 0 deletions server/app/src/main/kotlin/mu/muse/rest/Constants.kt
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
36 changes: 36 additions & 0 deletions server/app/src/main/kotlin/mu/muse/rest/logout/LogoutEndpoint.kt
Original file line number Diff line number Diff line change
@@ -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<Any> {
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()
}
}

0 comments on commit 4ce890f

Please sign in to comment.