diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a0b4b1e..9aa8a74 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2964,6 +2964,14 @@ "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.10.1.tgz", "integrity": "sha512-zg7Hz2k5lI8kb7U32998pRRFin7zJlkfezGJjUc2heaD4Pw2wObakCDVzkKztTm/Ln7eiVvYsjqak0Ed4LkMDA==" }, + "axios": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.0.tgz", + "integrity": "sha512-fmkJBknJKoZwem3/IKSSLpkdNXZeBu5Q7GA/aRsr2btgrptmSCxi2oFjZHqGdK9DoTil9PIHlPIZw2EcRJXRvw==", + "requires": { + "follow-redirects": "^1.10.0" + } + }, "axobject-query": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4cec5d3..be43df1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "@types/react-router-dom": "5.1.5", "@types/recharts": "1.8.15", "autoprefixer": "9.8.6", + "axios": "^0.21.0", "date-fns": "2.16.1", "npm-run-all": "4.1.5", "postcss-cli": "7.1.1", diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index a53a21e..ec02fc4 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -1,5 +1,5 @@ +import { AxiosInstance } from "axios"; import { formatISO, parseISO } from "date-fns"; -import { qs } from "../util/queryString"; import { Listen } from "./entities/listen"; import { ListenReportItem } from "./entities/listen-report-item"; import { ListenReportOptions } from "./entities/listen-report-options"; @@ -7,58 +7,18 @@ import { Pagination } from "./entities/pagination"; import { PaginationOptions } from "./entities/pagination-options"; import { TopArtistsItem } from "./entities/top-artists-item"; import { TopArtistsOptions } from "./entities/top-artists-options"; -import { User } from "./entities/user"; export class UnauthenticatedError extends Error {} -const getToken = (): string => { - const cookieValue = document.cookie.replace( - /(?:(?:^|.*;\s*)listory_access_token\s*=\s*([^;]*).*$)|^.*$/, - "$1" - ); - - return cookieValue; -}; - -const getDefaultHeaders = (): Headers => { - const headers = new Headers(); - - headers.append("Content-Type", "application/json"); - headers.append("Authorization", `Bearer ${getToken()}`); - - return headers; -}; - -export const getUsersMe = async (): Promise => { - const res = await fetch(`/api/v1/users/me`, { headers: getDefaultHeaders() }); - - switch (res.status) { - case 200: { - break; - } - case 401: { - throw new UnauthenticatedError(`No token or token expired`); - } - default: { - throw new Error(`Unable to getUsersMe: ${res.status}`); - } - } - - const user: User = await res.json(); - return user; -}; - export const getRecentListens = async ( - options: PaginationOptions = { page: 1, limit: 10 } + options: PaginationOptions = { page: 1, limit: 10 }, + client: AxiosInstance ): Promise> => { const { page, limit } = options; - const res = await fetch( - `/api/v1/listens?${qs({ page: page.toString(), limit: limit.toString() })}`, - { - headers: getDefaultHeaders(), - } - ); + const res = await client.get>(`/api/v1/listens`, { + params: { page, limit }, + }); switch (res.status) { case 200: { @@ -72,27 +32,27 @@ export const getRecentListens = async ( } } - const listens: Pagination = await res.json(); - return listens; + return res.data; }; export const getListensReport = async ( - options: ListenReportOptions + options: ListenReportOptions, + client: AxiosInstance ): Promise => { const { timeFrame, time: { timePreset, customTimeStart, customTimeEnd }, } = options; - const res = await fetch( - `/api/v1/reports/listens?${qs({ - timeFrame, - timePreset, - customTimeStart: formatISO(customTimeStart), - customTimeEnd: formatISO(customTimeEnd), - })}`, + const res = await client.get<{ items: { count: number; date: string }[] }>( + `/api/v1/reports/listens`, { - headers: getDefaultHeaders(), + params: { + timeFrame, + timePreset, + customTimeStart: formatISO(customTimeStart), + customTimeEnd: formatISO(customTimeEnd), + }, } ); @@ -108,25 +68,28 @@ export const getListensReport = async ( } } - const rawItems: { count: number; date: string }[] = (await res.json()).items; + const { + data: { items: rawItems }, + } = res; return rawItems.map(({ count, date }) => ({ count, date: parseISO(date) })); }; export const getTopArtists = async ( - options: TopArtistsOptions + options: TopArtistsOptions, + client: AxiosInstance ): Promise => { const { time: { timePreset, customTimeStart, customTimeEnd }, } = options; - const res = await fetch( - `/api/v1/reports/top-artists?${qs({ - timePreset, - customTimeStart: formatISO(customTimeStart), - customTimeEnd: formatISO(customTimeEnd), - })}`, + const res = await client.get<{ items: TopArtistsItem[] }>( + `/api/v1/reports/top-artists`, { - headers: getDefaultHeaders(), + params: { + timePreset, + customTimeStart: formatISO(customTimeStart), + customTimeEnd: formatISO(customTimeEnd), + }, } ); @@ -142,6 +105,8 @@ export const getTopArtists = async ( } } - const items: TopArtistsItem[] = (await res.json()).items; + const { + data: { items }, + } = res; return items; }; diff --git a/frontend/src/api/auth-api.ts b/frontend/src/api/auth-api.ts new file mode 100644 index 0000000..6c35db0 --- /dev/null +++ b/frontend/src/api/auth-api.ts @@ -0,0 +1,56 @@ +/* + * These calls are seperate from the others because they are only + * used in the useAuth hook which is used before the useApiClient hook. + * + * They do not use the apiClient/axios. + */ + +import { UnauthenticatedError } from "./api"; +import { RefreshTokenResponse } from "./entities/refresh-token-response"; +import { User } from "./entities/user"; + +export const getUsersMe = async (accessToken: string): Promise => { + const res = await fetch(`/api/v1/users/me`, { + headers: new Headers({ + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }), + }); + + switch (res.status) { + case 200: { + break; + } + case 401: { + throw new UnauthenticatedError(`No token or token expired`); + } + default: { + throw new Error(`Unable to getUsersMe: ${res.status}`); + } + } + + const user: User = await res.json(); + return user; +}; + +export const postAuthTokenRefresh = async (): Promise => { + const res = await fetch(`/api/v1/auth/token/refresh`, { + method: "POST", + headers: new Headers({ "Content-Type": "application/json" }), + }); + + switch (res.status) { + case 201: { + break; + } + case 401: { + throw new UnauthenticatedError(`No Refresh Token or token expired`); + } + default: { + throw new Error(`Unable to postAuthTokenRefresh: ${res.status}`); + } + } + + const refreshTokenResponse: RefreshTokenResponse = await res.json(); + return refreshTokenResponse; +}; diff --git a/frontend/src/api/entities/refresh-token-response.ts b/frontend/src/api/entities/refresh-token-response.ts new file mode 100644 index 0000000..cef70a2 --- /dev/null +++ b/frontend/src/api/entities/refresh-token-response.ts @@ -0,0 +1,3 @@ +export interface RefreshTokenResponse { + accessToken: string; +} diff --git a/frontend/src/components/RecentListens.tsx b/frontend/src/components/RecentListens.tsx index ce117fd..5086443 100644 --- a/frontend/src/components/RecentListens.tsx +++ b/frontend/src/components/RecentListens.tsx @@ -1,8 +1,8 @@ import { format, formatDistanceToNow } from "date-fns"; -import React, { useEffect, useState, useMemo } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { Redirect } from "react-router-dom"; -import { getRecentListens } from "../api/api"; import { Listen } from "../api/entities/listen"; +import { useRecentListens } from "../hooks/use-api"; import { useAuth } from "../hooks/use-auth"; import { ReloadIcon } from "../icons/Reload"; import { getPaginationItems } from "../util/getPaginationItems"; @@ -14,35 +14,18 @@ export const RecentListens: React.FC = () => { const [page, setPage] = useState(1); const [totalPages, setTotalPages] = useState(1); - const [listens, setListens] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const loadListensForPage = useMemo( - () => async () => { - setIsLoading(true); + const options = useMemo(() => ({ page, limit: LISTENS_PER_PAGE }), [page]); - try { - const listensFromApi = await getRecentListens({ - page, - limit: LISTENS_PER_PAGE, - }); - - if (totalPages !== listensFromApi.meta.totalPages) { - setTotalPages(listensFromApi.meta.totalPages); - } - setListens(listensFromApi.items); - } catch (err) { - console.error("Error while fetching recent listens:", err); - } finally { - setIsLoading(false); - } - }, - [page, totalPages, setIsLoading, setTotalPages, setListens] + const { recentListens, paginationMeta, isLoading, reload } = useRecentListens( + options ); useEffect(() => { - loadListensForPage(); - }, [loadListensForPage]); + if (paginationMeta && totalPages !== paginationMeta.totalPages) { + setTotalPages(paginationMeta.totalPages); + } + }, [totalPages, paginationMeta]); if (!user) { return ; @@ -55,7 +38,7 @@ export const RecentListens: React.FC = () => {

Recent listens

@@ -66,14 +49,14 @@ export const RecentListens: React.FC = () => { Loading Listens )} - {listens.length === 0 && ( + {recentListens.length === 0 && (

Could not find any listens!

)} - {listens.length > 0 && ( + {recentListens.length > 0 && (
- {listens.map((listen) => ( + {recentListens.map((listen) => ( ))}
diff --git a/frontend/src/components/ReportListens.tsx b/frontend/src/components/ReportListens.tsx index 3d0776c..fb24b47 100644 --- a/frontend/src/components/ReportListens.tsx +++ b/frontend/src/components/ReportListens.tsx @@ -11,12 +11,11 @@ import { XAxis, YAxis, } from "recharts"; -import { getListensReport } from "../api/api"; import { ListenReportItem } from "../api/entities/listen-report-item"; import { ListenReportOptions } from "../api/entities/listen-report-options"; import { TimeOptions } from "../api/entities/time-options"; import { TimePreset } from "../api/entities/time-preset.enum"; -import { useAsync } from "../hooks/use-async"; +import { useListensReport } from "../hooks/use-api"; import { useAuth } from "../hooks/use-auth"; import { ReportTimeOptions } from "./ReportTimeOptions"; @@ -33,12 +32,12 @@ export const ReportListens: React.FC = () => { customTimeEnd: new Date(), }); - const fetchData = useMemo( - () => () => getListensReport({ timeFrame, time: timeOptions }), - [timeFrame, timeOptions] - ); + const reportOptions = useMemo(() => ({ timeFrame, time: timeOptions }), [ + timeFrame, + timeOptions, + ]); - const { value: report, pending: isLoading } = useAsync(fetchData, []); + const { report, isLoading } = useListensReport(reportOptions); const reportHasItems = !isLoading && report.length !== 0; diff --git a/frontend/src/components/ReportTopArtists.tsx b/frontend/src/components/ReportTopArtists.tsx index 6734399..c55808a 100644 --- a/frontend/src/components/ReportTopArtists.tsx +++ b/frontend/src/components/ReportTopArtists.tsx @@ -1,15 +1,11 @@ import React, { useMemo, useState } from "react"; import { Redirect } from "react-router-dom"; -import { getTopArtists } from "../api/api"; import { TimeOptions } from "../api/entities/time-options"; import { TimePreset } from "../api/entities/time-preset.enum"; -import { TopArtistsItem } from "../api/entities/top-artists-item"; -import { useAsync } from "../hooks/use-async"; +import { useTopArtists } from "../hooks/use-api"; import { useAuth } from "../hooks/use-auth"; import { ReportTimeOptions } from "./ReportTimeOptions"; -const INITIAL_REPORT_DATA: TopArtistsItem[] = []; - export const ReportTopArtists: React.FC = () => { const { user } = useAuth(); @@ -19,16 +15,16 @@ export const ReportTopArtists: React.FC = () => { customTimeEnd: new Date(), }); - const fetchData = useMemo(() => () => getTopArtists({ time: timeOptions }), [ - timeOptions, - ]); - - const { value: report, pending: isLoading } = useAsync( - fetchData, - INITIAL_REPORT_DATA + const options = useMemo( + () => ({ + time: timeOptions, + }), + [timeOptions] ); - const reportHasItems = !isLoading && report.length !== 0; + const { topArtists, isLoading } = useTopArtists(options); + + const reportHasItems = !isLoading && topArtists.length !== 0; if (!user) { return ; @@ -56,7 +52,7 @@ export const ReportTopArtists: React.FC = () => { )} {reportHasItems && - report.map(({ artist, count }) => ( + topArtists.map(({ artist, count }) => (
{count} - {artist.name}
diff --git a/frontend/src/hooks/use-api-client.tsx b/frontend/src/hooks/use-api-client.tsx new file mode 100644 index 0000000..3fead67 --- /dev/null +++ b/frontend/src/hooks/use-api-client.tsx @@ -0,0 +1,95 @@ +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; +import React, { + createContext, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { useAuth } from "./use-auth"; + +interface ApiClientContext { + client: AxiosInstance; +} + +const apiClientContext = createContext( + (undefined as any) as ApiClientContext +); + +export const ProvideApiClient: React.FC = ({ children }) => { + const auth = useProvideApiClient(); + + return ( + + {children} + + ); +}; + +export function useApiClient() { + return useContext(apiClientContext); +} + +function useProvideApiClient(): ApiClientContext { + const { accessToken, refreshAccessToken } = useAuth(); + + // Wrap value to immediatly update when refreshing access token + // and always having access to newest access token in interceptor + const localAccessToken = useRef(accessToken); + + // initialState must be passed as function as return value of axios.create() + // is also callable and react will call it and then use that result (promise) + // as the initial state instead of the axios instace. + const [client] = useState(() => axios.create()); + + useEffect(() => { + localAccessToken.current = accessToken; + }, [localAccessToken, accessToken]); + + // TODO Implement lock to avoid multiple parallel refreshes, maybe in useAuth? + + useEffect(() => { + // Setup Axios Interceptors + const requestInterceptor = client.interceptors.request.use( + (config) => { + if (!config.headers) { + config.headers = {}; + } + + config.headers.Authorization = `Bearer ${localAccessToken.current}`; + + return config; + }, + (err) => Promise.reject(err) + ); + const responseInterceptor = client.interceptors.response.use( + (data) => data, + async (err: any) => { + if (!err.response || !err.config) { + throw err; + } + + const { response, config } = err as { + response: AxiosResponse; + config: AxiosRequestConfig; + }; + + if (response && response.status !== 401) { + throw err; + } + + // TODO error handling + localAccessToken.current = await refreshAccessToken(); + + return client.request(config); + } + ); + + return () => { + client.interceptors.request.eject(requestInterceptor); + client.interceptors.response.eject(responseInterceptor); + }; + }, [client, localAccessToken, refreshAccessToken]); + + return { client }; +} diff --git a/frontend/src/hooks/use-api.tsx b/frontend/src/hooks/use-api.tsx new file mode 100644 index 0000000..9d4513c --- /dev/null +++ b/frontend/src/hooks/use-api.tsx @@ -0,0 +1,65 @@ +import { useMemo, useState } from "react"; +import { getListensReport, getRecentListens, getTopArtists } from "../api/api"; +import { ListenReportOptions } from "../api/entities/listen-report-options"; +import { PaginationOptions } from "../api/entities/pagination-options"; +import { TopArtistsOptions } from "../api/entities/top-artists-options"; +import { useApiClient } from "./use-api-client"; +import { useAsync } from "./use-async"; + +const INITIAL_EMPTY_ARRAY: [] = []; +Object.freeze(INITIAL_EMPTY_ARRAY); + +export const useRecentListens = (options: PaginationOptions) => { + const { client } = useApiClient(); + + const fetchData = useMemo(() => () => getRecentListens(options, client), [ + options, + client, + ]); + + const { value, pending: isLoading, error, reload } = useAsync( + fetchData, + undefined + ); + + const recentListens = value ? value.items : []; + const paginationMeta = value ? value.meta : undefined; + + return { recentListens, paginationMeta, isLoading, error, reload }; +}; + +export const useListensReport = (options: ListenReportOptions) => { + const { client } = useApiClient(); + + const [initialData] = useState(INITIAL_EMPTY_ARRAY); + + const fetchData = useMemo(() => () => getListensReport(options, client), [ + options, + client, + ]); + + const { value: report, pending: isLoading, error } = useAsync( + fetchData, + initialData + ); + + return { report, isLoading, error }; +}; + +export const useTopArtists = (options: TopArtistsOptions) => { + const { client } = useApiClient(); + + const [initialData] = useState(INITIAL_EMPTY_ARRAY); + + const fetchData = useMemo(() => () => getTopArtists(options, client), [ + options, + client, + ]); + + const { value: topArtists, pending: isLoading, error } = useAsync( + fetchData, + initialData + ); + + return { topArtists, isLoading, error }; +}; diff --git a/frontend/src/hooks/use-async.tsx b/frontend/src/hooks/use-async.tsx index 0220b23..004f8c5 100644 --- a/frontend/src/hooks/use-async.tsx +++ b/frontend/src/hooks/use-async.tsx @@ -3,7 +3,12 @@ import { useCallback, useEffect, useState } from "react"; type UseAsync = ( asyncFunction: () => Promise, initialValue: T -) => { pending: boolean; value: T; error: Error | null }; +) => { + pending: boolean; + value: T; + error: Error | null; + reload: () => Promise; +}; export const useAsync: UseAsync = ( asyncFunction: () => Promise, @@ -34,5 +39,5 @@ export const useAsync: UseAsync = ( execute(); }, [execute]); - return { execute, pending, value, error }; + return { reload: execute, pending, value, error }; }; diff --git a/frontend/src/hooks/use-auth.tsx b/frontend/src/hooks/use-auth.tsx index ad4679f..a27a05a 100644 --- a/frontend/src/hooks/use-auth.tsx +++ b/frontend/src/hooks/use-auth.tsx @@ -1,20 +1,26 @@ -import React, { createContext, useContext, useEffect, useState } from "react"; -import { getUsersMe, UnauthenticatedError } from "../api/api"; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import { getUsersMe, postAuthTokenRefresh } from "../api/auth-api"; import { User } from "../api/entities/user"; +interface AuthContext { + isLoaded: boolean; + user: User | null; + accessToken: string; + error: Error | null; + refreshAccessToken: () => Promise; + loginWithSpotifyProps: () => { href: string }; +} + const authContext = createContext( (undefined as any) as AuthContext ); -// Provider component that wraps your app and makes auth object ... -// ... available to any child component that calls useAuth(). - -interface AuthContext { - user: { id: string; displayName: string } | null; - isLoaded: boolean; - loginWithSpotifyProps: () => { href: string }; -} - export const ProvideAuth: React.FC = ({ children }) => { const auth = useProvideAuth(); @@ -28,25 +34,51 @@ export function useAuth() { function useProvideAuth(): AuthContext { const [isLoaded, setIsLoaded] = useState(false); const [user, setUser] = useState(null); + const [accessToken, setAccessToken] = useState(""); + const [error, setError] = useState(null); const loginWithSpotifyProps = () => ({ href: "/api/v1/auth/spotify" }); - useEffect(() => { - (async () => { - try { - const currentUser = await getUsersMe(); - setUser(currentUser); - } catch (err) { - if (err instanceof UnauthenticatedError) { - // User is not logged in - } else { - console.error("Error while checking login state:", err); - } - } finally { - setIsLoaded(true); - } - })(); + const refreshAccessToken = useCallback(async () => { + try { + const { accessToken: newAccessToken } = await postAuthTokenRefresh(); + setAccessToken(newAccessToken); + return newAccessToken; + } catch (err) { + setAccessToken(""); + setUser(null); + setIsLoaded(true); + setError(err); + + throw err; + } }, []); - return { isLoaded, user, loginWithSpotifyProps }; + useEffect(() => { + refreshAccessToken().catch(() => { + console.log("Unable to refresh access token"); + }); + }, [refreshAccessToken]); + + useEffect(() => { + if (!accessToken) { + return; + } + async function getUser(token: string) { + const newUser = await getUsersMe(token); + setUser(newUser); + setIsLoaded(true); + } + + getUser(accessToken); + }, [accessToken]); + + return { + isLoaded, + user, + accessToken, + error, + refreshAccessToken, + loginWithSpotifyProps, + }; } diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 2861d30..95c4e8a 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -2,15 +2,18 @@ import React from "react"; import ReactDOM from "react-dom"; import { BrowserRouter } from "react-router-dom"; import { App } from "./App"; +import { ProvideApiClient } from "./hooks/use-api-client"; import { ProvideAuth } from "./hooks/use-auth"; import * as serviceWorker from "./serviceWorker"; ReactDOM.render( - - - + + + + + , document.getElementById("root") diff --git a/package-lock.json b/package-lock.json index fcc9f92..9cee5ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1820,6 +1820,15 @@ "@types/node": "*" } }, + "@types/cookie-parser": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.2.tgz", + "integrity": "sha512-uwcY8m6SDQqciHsqcKDGbo10GdasYsPCYkH3hVegj9qAah6pX5HivOnOuI3WYmyQMnOATV39zv/Ybs0bC/6iVg==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, "@types/cookiejar": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.1.tgz", @@ -3635,6 +3644,15 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" }, + "cookie-parser": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.5.tgz", + "integrity": "sha512-f13bPUj/gG/5mDr+xLmSxxDsB9DQiTIfhJS/sqjrmfAWiAN+x2O4i/XguTL9yDZ+/IFDanJ+5x7hC4CXT9Tdzw==", + "requires": { + "cookie": "0.4.0", + "cookie-signature": "1.0.6" + } + }, "cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", diff --git a/package.json b/package.json index 53d42fc..11a1939 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@nestjs/typeorm": "7.1.0", "class-transformer": "0.3.1", "class-validator": "0.12.2", + "cookie-parser": "^1.4.5", "date-fns": "2.16.1", "nestjs-typeorm-paginate": "2.1.1", "passport": "0.4.1", @@ -53,6 +54,7 @@ "@nestjs/cli": "7.4.1", "@nestjs/schematics": "7.0.1", "@nestjs/testing": "7.3.1", + "@types/cookie-parser": "^1.4.2", "@types/express": "4.17.8", "@types/hapi__joi": "17.1.4", "@types/jest": "26.0.13", diff --git a/src/auth/auth-session.entity.ts b/src/auth/auth-session.entity.ts new file mode 100644 index 0000000..1e24e27 --- /dev/null +++ b/src/auth/auth-session.entity.ts @@ -0,0 +1,26 @@ +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, +} from "typeorm"; +import { User } from "../users/user.entity"; + +@Entity() +export class AuthSession { + @PrimaryGeneratedColumn("uuid") + id: string; + + @ManyToOne((type) => User, { eager: true }) + user: User; + + @CreateDateColumn() + createdAt: Date; + + @Column({ type: "timestamp", default: () => "CURRENT_TIMESTAMP" }) + lastUsedAt: Date; + + @Column({ type: "timestamp", nullable: true }) + revokedAt: Date | null; +} diff --git a/src/auth/auth-session.repository.ts b/src/auth/auth-session.repository.ts new file mode 100644 index 0000000..eaf0d36 --- /dev/null +++ b/src/auth/auth-session.repository.ts @@ -0,0 +1,23 @@ +// tslint:disable: max-classes-per-file +import { EntityRepository, Repository, SelectQueryBuilder } from "typeorm"; +import { User } from "../users/user.entity"; +import { AuthSession } from "./auth-session.entity"; + +export class AuthSessionScopes extends SelectQueryBuilder { + /** + * `byUser` scopes the query to AuthSessions created by the user. + * @param currentUser + */ + byUser(currentUser: User): this { + return this.andWhere(`session."userId" = :userID`, { + userID: currentUser.id, + }); + } +} + +@EntityRepository(AuthSession) +export class AuthSessionRepository extends Repository { + get scoped(): AuthSessionScopes { + return new AuthSessionScopes(this.createQueryBuilder("session")); + } +} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index eead627..25ba701 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,10 +1,23 @@ -import { Controller, Get, Res, UseFilters, UseGuards } from "@nestjs/common"; +import { + Controller, + Get, + Post, + Res, + UseFilters, + UseGuards, +} from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; -import { AuthGuard } from "@nestjs/passport"; import { Response } from "express"; import { User } from "../users/user.entity"; +import { AuthSession } from "./auth-session.entity"; import { AuthService } from "./auth.service"; +import { COOKIE_REFRESH_TOKEN } from "./constants"; import { ReqUser } from "./decorators/req-user.decorator"; +import { RefreshAccessTokenResponseDto } from "./dto/refresh-access-token-response.dto"; +import { + RefreshTokenAuthGuard, + SpotifyAuthGuard, +} from "./guards/auth-strategies.guard"; import { SpotifyAuthFilter } from "./spotify.filter"; @Controller("api/v1/auth") @@ -15,26 +28,33 @@ export class AuthController { ) {} @Get("spotify") - @UseGuards(AuthGuard("spotify")) + @UseGuards(SpotifyAuthGuard) spotifyRedirect() { // User is redirected by AuthGuard } @Get("spotify/callback") @UseFilters(SpotifyAuthFilter) - @UseGuards(AuthGuard("spotify")) + @UseGuards(SpotifyAuthGuard) async spotifyCallback(@ReqUser() user: User, @Res() res: Response) { - const { accessToken } = await this.authService.createToken(user); + const { refreshToken } = await this.authService.createSession(user); - // Transmit accessToken to Frontend - res.cookie("listory_access_token", accessToken, { - maxAge: 24 * 60 * 60 * 1000, // 1 day - - // Must be readable by SPA - httpOnly: false, - }); + // Refresh token should not be accessible to frontend to reduce risk + // of XSS attacks. + res.cookie(COOKIE_REFRESH_TOKEN, refreshToken, { httpOnly: true }); // Redirect User to SPA res.redirect("/login/success?source=spotify"); } + + @Post("token/refresh") + @UseGuards(RefreshTokenAuthGuard) + async refreshAccessToken( + // With RefreshTokenAuthGuard the session is available instead of user + @ReqUser() session: AuthSession + ): Promise { + const { accessToken } = await this.authService.createAccessToken(session); + + return { accessToken }; + } } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index ae9f6fc..b4f0a1c 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,15 +1,20 @@ -import { Module } from "@nestjs/common"; +import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { JwtModule } from "@nestjs/jwt"; import { PassportModule } from "@nestjs/passport"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { CookieParserMiddleware } from "../cookie-parser"; import { UsersModule } from "../users/users.module"; +import { AuthSessionRepository } from "./auth-session.repository"; import { AuthController } from "./auth.controller"; import { AuthService } from "./auth.service"; -import { JwtStrategy } from "./jwt.strategy"; -import { SpotifyStrategy } from "./spotify.strategy"; +import { AccessTokenStrategy } from "./strategies/access-token.strategy"; +import { RefreshTokenStrategy } from "./strategies/refresh-token.strategy"; +import { SpotifyStrategy } from "./strategies/spotify.strategy"; @Module({ imports: [ + TypeOrmModule.forFeature([AuthSessionRepository]), PassportModule.register({ defaultStrategy: "jwt" }), JwtModule.registerAsync({ useFactory: (config: ConfigService) => ({ @@ -23,8 +28,17 @@ import { SpotifyStrategy } from "./spotify.strategy"; }), UsersModule, ], - providers: [AuthService, SpotifyStrategy, JwtStrategy], + providers: [ + AuthService, + SpotifyStrategy, + AccessTokenStrategy, + RefreshTokenStrategy, + ], exports: [PassportModule], controllers: [AuthController], }) -export class AuthModule {} +export class AuthModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(CookieParserMiddleware).forRoutes("api/v1/auth"); + } +} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index d1c2b96..d59dc13 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,19 +1,27 @@ -import { Injectable, ForbiddenException } from "@nestjs/common"; +import { ForbiddenException, Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { JwtService } from "@nestjs/jwt"; import { User } from "../users/user.entity"; import { UsersService } from "../users/users.service"; +import { AuthSession } from "./auth-session.entity"; +import { AuthSessionRepository } from "./auth-session.repository"; import { LoginDto } from "./dto/login.dto"; @Injectable() export class AuthService { private readonly userFilter: null | string; + private readonly sessionExpirationTime: string; + constructor( private readonly config: ConfigService, private readonly usersService: UsersService, - private readonly jwtService: JwtService + private readonly jwtService: JwtService, + private readonly authSessionRepository: AuthSessionRepository ) { this.userFilter = this.config.get("SPOTIFY_USER_FILTER"); + this.sessionExpirationTime = this.config.get( + "SESSION_EXPIRATION_TIME" + ); } async spotifyLogin({ @@ -38,6 +46,67 @@ export class AuthService { return user; } + async createSession( + user: User + ): Promise<{ + session: AuthSession; + refreshToken: string; + }> { + const session = this.authSessionRepository.create(); + + session.user = user; + await this.authSessionRepository.save(session); + + const [{ refreshToken }] = await Promise.all([ + this.createRefreshToken(session), + this.createAccessToken(session), + ]); + + return { session, refreshToken }; + } + + /** + * createRefreshToken should only be used while creating a new session. + * @param session + */ + private async createRefreshToken( + session: AuthSession + ): Promise<{ refreshToken }> { + const payload = { + sub: session.user.id, + name: session.user.displayName, + }; + + const token = await this.jwtService.signAsync(payload, { + jwtid: session.id, + // jwtService uses the shorter access token time as a default + expiresIn: this.sessionExpirationTime, + }); + + return { refreshToken: token }; + } + + async createAccessToken(session: AuthSession): Promise<{ accessToken }> { + if (session.revokedAt) { + throw new ForbiddenException("SessionIsRevoked"); + } + + const payload = { + sub: session.user.id, + name: session.user.displayName, + picture: session.user.photo, + }; + + const token = await this.jwtService.signAsync(payload); + + return { accessToken: token }; + } + + /** + * Switch to createAccessToken + * @deprecated + * @param user + */ async createToken(user: User): Promise<{ accessToken }> { const payload = { sub: user.id, @@ -50,6 +119,10 @@ export class AuthService { return { accessToken: token }; } + async findSession(id: string): Promise { + return this.authSessionRepository.findOne(id); + } + async findUser(id: string): Promise { return this.usersService.findById(id); } diff --git a/src/auth/constants.ts b/src/auth/constants.ts new file mode 100644 index 0000000..87a8fa6 --- /dev/null +++ b/src/auth/constants.ts @@ -0,0 +1 @@ +export const COOKIE_REFRESH_TOKEN = "listory_refresh_token"; diff --git a/src/auth/decorators/auth.decorator.ts b/src/auth/decorators/auth-access-token.decorator.ts similarity index 63% rename from src/auth/decorators/auth.decorator.ts rename to src/auth/decorators/auth-access-token.decorator.ts index 566a4bc..5fbaad7 100644 --- a/src/auth/decorators/auth.decorator.ts +++ b/src/auth/decorators/auth-access-token.decorator.ts @@ -1,10 +1,10 @@ import { applyDecorators, UseGuards } from "@nestjs/common"; -import { AuthGuard } from "@nestjs/passport"; import { ApiBearerAuth, ApiUnauthorizedResponse } from "@nestjs/swagger"; +import { AccessTokenAuthGuard } from "../guards/auth-strategies.guard"; -export function Auth() { +export function AuthAccessToken() { return applyDecorators( - UseGuards(AuthGuard("jwt")), + UseGuards(AccessTokenAuthGuard), ApiBearerAuth(), ApiUnauthorizedResponse({ description: 'Unauthorized"' }) ); diff --git a/src/auth/dto/refresh-access-token-response.dto.ts b/src/auth/dto/refresh-access-token-response.dto.ts new file mode 100644 index 0000000..6443629 --- /dev/null +++ b/src/auth/dto/refresh-access-token-response.dto.ts @@ -0,0 +1,3 @@ +export class RefreshAccessTokenResponseDto { + accessToken: string; +} diff --git a/src/auth/guards/auth-strategies.guard.ts b/src/auth/guards/auth-strategies.guard.ts new file mode 100644 index 0000000..fb0366b --- /dev/null +++ b/src/auth/guards/auth-strategies.guard.ts @@ -0,0 +1,9 @@ +import { AuthGuard } from "@nestjs/passport"; +import { AuthStrategy } from "../strategies/strategies.enum"; + +// Internal +export const AccessTokenAuthGuard = AuthGuard(AuthStrategy.AccessToken); +export const RefreshTokenAuthGuard = AuthGuard(AuthStrategy.RefreshToken); + +// Auth Provider +export const SpotifyAuthGuard = AuthGuard(AuthStrategy.Spotify); diff --git a/src/auth/jwt.strategy.ts b/src/auth/strategies/access-token.strategy.ts similarity index 62% rename from src/auth/jwt.strategy.ts rename to src/auth/strategies/access-token.strategy.ts index 0550358..3df438f 100644 --- a/src/auth/jwt.strategy.ts +++ b/src/auth/strategies/access-token.strategy.ts @@ -1,11 +1,15 @@ import { Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { PassportStrategy } from "@nestjs/passport"; -import { Strategy, ExtractJwt } from "passport-jwt"; -import { AuthService } from "./auth.service"; +import { ExtractJwt, Strategy } from "passport-jwt"; +import { AuthService } from "../auth.service"; +import { AuthStrategy } from "./strategies.enum"; @Injectable() -export class JwtStrategy extends PassportStrategy(Strategy) { +export class AccessTokenStrategy extends PassportStrategy( + Strategy, + AuthStrategy.AccessToken +) { constructor( private readonly authService: AuthService, config: ConfigService @@ -17,7 +21,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } - async validate(payload: any) { + async validate(payload: { sub: string }) { return this.authService.findUser(payload.sub); } } diff --git a/src/auth/strategies/refresh-token.strategy.ts b/src/auth/strategies/refresh-token.strategy.ts new file mode 100644 index 0000000..0a9209b --- /dev/null +++ b/src/auth/strategies/refresh-token.strategy.ts @@ -0,0 +1,48 @@ +import { + Injectable, + UnauthorizedException, + ForbiddenException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PassportStrategy } from "@nestjs/passport"; +import { JwtFromRequestFunction, Strategy } from "passport-jwt"; +import { AuthService } from "../auth.service"; +import { COOKIE_REFRESH_TOKEN } from "../constants"; +import { AuthStrategy } from "./strategies.enum"; +import { AuthSession } from "../auth-session.entity"; + +const extractJwtFromCookie: JwtFromRequestFunction = (req) => { + const token = req.cookies[COOKIE_REFRESH_TOKEN] || null; + return token; +}; + +@Injectable() +export class RefreshTokenStrategy extends PassportStrategy( + Strategy, + AuthStrategy.RefreshToken +) { + constructor( + private readonly authService: AuthService, + config: ConfigService + ) { + super({ + jwtFromRequest: extractJwtFromCookie, + ignoreExpiration: false, + secretOrKey: config.get("JWT_SECRET"), + }); + } + + async validate(payload: { jti: string }): Promise { + const session = await this.authService.findSession(payload.jti); + + if (!session) { + throw new UnauthorizedException("SessionNotFound"); + } + + if (session.revokedAt) { + throw new ForbiddenException("SessionIsRevoked"); + } + + return session; + } +} diff --git a/src/auth/spotify.strategy.ts b/src/auth/strategies/spotify.strategy.ts similarity index 81% rename from src/auth/spotify.strategy.ts rename to src/auth/strategies/spotify.strategy.ts index 0a47485..4283085 100644 --- a/src/auth/spotify.strategy.ts +++ b/src/auth/strategies/spotify.strategy.ts @@ -2,10 +2,14 @@ import { Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { PassportStrategy } from "@nestjs/passport"; import { Strategy } from "passport-spotify"; -import { AuthService } from "./auth.service"; +import { AuthService } from "../auth.service"; +import { AuthStrategy } from "./strategies.enum"; @Injectable() -export class SpotifyStrategy extends PassportStrategy(Strategy) { +export class SpotifyStrategy extends PassportStrategy( + Strategy, + AuthStrategy.Spotify +) { constructor( private readonly authService: AuthService, config: ConfigService diff --git a/src/auth/strategies/strategies.enum.ts b/src/auth/strategies/strategies.enum.ts new file mode 100644 index 0000000..cf19b9d --- /dev/null +++ b/src/auth/strategies/strategies.enum.ts @@ -0,0 +1,8 @@ +export enum AuthStrategy { + // Internal + AccessToken = "access_token", + RefreshToken = "refresh_token", + + // Auth Provider + Spotify = "spotify", +} diff --git a/src/config/config.module.ts b/src/config/config.module.ts index 112bb12..b9b9753 100644 --- a/src/config/config.module.ts +++ b/src/config/config.module.ts @@ -1,9 +1,6 @@ import * as Joi from "@hapi/joi"; import { Module } from "@nestjs/common"; -import { - ConfigModule as NestConfigModule, - ConfigService, -} from "@nestjs/config"; +import { ConfigModule as NestConfigModule } from "@nestjs/config"; @Module({ imports: [ @@ -19,7 +16,9 @@ import { JWT_ALGORITHM: Joi.string() .default("HS256") .allow("HS256", "HS384", "HS512"), - JWT_EXPIRATION_TIME: Joi.string().default("1d"), + + JWT_EXPIRATION_TIME: Joi.string().default("15m"), + SESSION_EXPIRATION_TIME: Joi.string().default("1y"), // Spotify SPOTIFY_CLIENT_ID: Joi.string().required(), diff --git a/src/cookie-parser/cookie-parser.middleware.ts b/src/cookie-parser/cookie-parser.middleware.ts new file mode 100644 index 0000000..1a3343f --- /dev/null +++ b/src/cookie-parser/cookie-parser.middleware.ts @@ -0,0 +1,16 @@ +import { Injectable, NestMiddleware } from "@nestjs/common"; +import * as cookieParser from "cookie-parser"; +import type { RequestHandler } from "express"; + +@Injectable() +export class CookieParserMiddleware implements NestMiddleware { + private readonly middleware: RequestHandler; + + constructor() { + this.middleware = cookieParser(); + } + + use(req: any, res: any, next: () => void) { + return this.middleware(req, res, next); + } +} diff --git a/src/cookie-parser/index.ts b/src/cookie-parser/index.ts new file mode 100644 index 0000000..38e9331 --- /dev/null +++ b/src/cookie-parser/index.ts @@ -0,0 +1 @@ +export { CookieParserMiddleware } from "./cookie-parser.middleware"; diff --git a/src/database/migrations/04-CreateAuthSessionsTable.ts b/src/database/migrations/04-CreateAuthSessionsTable.ts new file mode 100644 index 0000000..802cf9f --- /dev/null +++ b/src/database/migrations/04-CreateAuthSessionsTable.ts @@ -0,0 +1,69 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableIndex, + TableForeignKey, +} from "typeorm"; +import { TableColumnOptions } from "typeorm/schema-builder/options/TableColumnOptions"; + +const primaryUUIDColumn: TableColumnOptions = { + name: "id", + type: "uuid", + isPrimary: true, + isGenerated: true, + generationStrategy: "uuid", +}; + +export class CreateAuthSessionsTable0000000000004 + implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "auth_session", + columns: [ + primaryUUIDColumn, + { + name: "userId", + type: "uuid", + }, + { + name: "createdAt", + type: "timestamp", + default: "CURRENT_TIMESTAMP", + }, + { + name: "lastUsedAt", + type: "timestamp", + default: "CURRENT_TIMESTAMP", + }, + { + name: "revokedAt", + type: "timestamp", + default: null, + isNullable: true, + }, + ], + indices: [ + new TableIndex({ + name: "IDX_AUTH_SESSION_USER_ID", + columnNames: ["userId"], + }), + ], + foreignKeys: [ + new TableForeignKey({ + name: "FK_AUTH_SESSION_USER_ID", + columnNames: ["userId"], + referencedColumnNames: ["id"], + referencedTableName: "user", + }), + ], + }), + true + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("auth_session"); + } +} diff --git a/src/listens/listens.controller.ts b/src/listens/listens.controller.ts index d52fe06..0ad26fd 100644 --- a/src/listens/listens.controller.ts +++ b/src/listens/listens.controller.ts @@ -1,6 +1,6 @@ import { Controller, Get, Query } from "@nestjs/common"; import { Pagination } from "nestjs-typeorm-paginate"; -import { Auth } from "../auth/decorators/auth.decorator"; +import { AuthAccessToken } from "../auth/decorators/auth-access-token.decorator"; import { ReqUser } from "../auth/decorators/req-user.decorator"; import { User } from "../users/user.entity"; import { GetListensFilterDto } from "./dto/get-listens.dto"; @@ -12,7 +12,7 @@ export class ListensController { constructor(private readonly listensService: ListensService) {} @Get() - @Auth() + @AuthAccessToken() async getRecentlyPlayed( @Query("page") page: number = 1, @Query("limit") limit: number = 10, diff --git a/src/reports/reports.controller.ts b/src/reports/reports.controller.ts index 372c583..14e60b3 100644 --- a/src/reports/reports.controller.ts +++ b/src/reports/reports.controller.ts @@ -1,5 +1,5 @@ import { Controller, Get, Query } from "@nestjs/common"; -import { Auth } from "../auth/decorators/auth.decorator"; +import { AuthAccessToken } from "../auth/decorators/auth-access-token.decorator"; import { ReqUser } from "../auth/decorators/req-user.decorator"; import { User } from "../users/user.entity"; import { ListenReportDto } from "./dto/listen-report.dto"; @@ -13,7 +13,7 @@ export class ReportsController { constructor(private readonly reportsService: ReportsService) {} @Get("listens") - @Auth() + @AuthAccessToken() async getListens( @Query() time: ReportTimeDto, @Query("timeFrame") timeFrame: Timeframe, @@ -23,7 +23,7 @@ export class ReportsController { } @Get("top-artists") - @Auth() + @AuthAccessToken() async getTopArtists( @Query() time: ReportTimeDto, @ReqUser() user: User diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 47ac462..b91bf68 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -1,12 +1,12 @@ import { Controller, Get } from "@nestjs/common"; -import { Auth } from "../auth/decorators/auth.decorator"; +import { AuthAccessToken } from "../auth/decorators/auth-access-token.decorator"; import { ReqUser } from "../auth/decorators/req-user.decorator"; import { User } from "./user.entity"; @Controller("api/v1/users") export class UsersController { @Get("me") - @Auth() + @AuthAccessToken() getMe(@ReqUser() user: User): Omit { return { id: user.id,