diff --git a/apps/sensenet/package.json b/apps/sensenet/package.json index 5d08dc513..07d50a9e2 100644 --- a/apps/sensenet/package.json +++ b/apps/sensenet/package.json @@ -117,8 +117,5 @@ "semaphore-async-await": "^1.5.1", "uuid": "9.0.0" }, - "typings": "./dist/index.d.ts", - "resolutions": { - "@types/react": "^18.2.7" - } -} + "typings": "./dist/index.d.ts" +} \ No newline at end of file diff --git a/apps/sensenet/src/context/auth-provider.tsx b/apps/sensenet/src/context/auth-provider.tsx index 10b336146..d72ae1c5b 100644 --- a/apps/sensenet/src/context/auth-provider.tsx +++ b/apps/sensenet/src/context/auth-provider.tsx @@ -69,7 +69,7 @@ export function ISAuthProvider({ children }: PropsWithChildren<{}>) { } export function SNAuthProvider({ children }: PropsWithChildren<{}>) { - const { user, login, logout } = useSnAuth() + const { user, externalLogin: login, logout } = useSnAuth() return ( import(/* webpackChunkName: "login" */ '../components/login/login-page')) -export const authConfigKey = 'sn-oidc-config' +export const authConfigKey = 'sn-auth-config' export function SnAuthRepositoryProvider({ children }: { children: React.ReactNode }) { const [isLoginInProgress, setIsLoginInProgress] = useState(false) @@ -120,7 +120,7 @@ const RepoProvider = ({ clearAuthState: Function authServerUrl?: string }) => { - const { user, login, logout, accessToken, isLoading } = useSnAuth() + const { user, externalLogin, logout, accessToken, isLoading } = useSnAuth() const logger = useLogger('repo-provider') const [repo, setRepo] = useState() @@ -167,7 +167,7 @@ const RepoProvider = ({ const configString = window.localStorage.getItem(authConfigKey) if (!user && !isLoading && !accessToken && configString) { try { - await login() + await externalLogin() } catch (error) { const config = JSON.parse(configString) logger.error({ data: error, message: `Couldn't connect to ${config.authority}` }) @@ -176,7 +176,7 @@ const RepoProvider = ({ } } })() - }, [clearAuthState, logger, login, logout, user, isLoading, accessToken]) + }, [clearAuthState, logger, externalLogin, logout, user, isLoading, accessToken]) if (!user || !repo) { return null diff --git a/packages/sn-auth-react/package.json b/packages/sn-auth-react/package.json index 9a4bb6b7d..5c9949a41 100644 --- a/packages/sn-auth-react/package.json +++ b/packages/sn-auth-react/package.json @@ -28,10 +28,17 @@ "build:cjs": "cross-env BABEL_ENV=cjs babel src --extensions '.ts,.tsx' --out-dir 'dist/cjs' --source-maps", "build:types": "tsc -p tsconfig.json" }, + "peerDependencies": { + "@material-ui/core": "^4.0.0", + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + }, "dependencies": { "@babel/runtime": "^7.18.9", "@material-ui/core": "^4.12.4", - "tslib": "^2.4.0" + "tslib": "^2.4.0", + "react": "^16.13.0", + "react-dom": "^16.13.0" }, "devDependencies": { "@babel/cli": "^7.14.9", @@ -43,6 +50,8 @@ "@testing-library/jest-dom": "^6.5.0", "@testing-library/react": "^16.0.1", "@types/node": "^16.4.10", + "@types/react": "^17.0.15", + "@types/react-dom": "^17.0.9", "axios": "^1.7.7", "cross-env": "^7.0.3", "jest": "^29.7.0", @@ -52,4 +61,4 @@ "ts-jest": "^27.0.5", "typescript": "~4.7.4" } -} +} \ No newline at end of file diff --git a/packages/sn-auth-react/src/components/authentication-provider.tsx b/packages/sn-auth-react/src/components/authentication-provider.tsx index f6458e6a6..a84aa2d91 100644 --- a/packages/sn-auth-react/src/components/authentication-provider.tsx +++ b/packages/sn-auth-react/src/components/authentication-provider.tsx @@ -2,7 +2,7 @@ import React, { createContext, ReactNode, useState, useEffect } from 'react' import { User } from '../models/user' import { AuthRoutes } from './auth-routes' import { SnAuthConfiguration } from '../models/sn-auth-configuration' -import { convertAuthTokenApiCall, getUserDetailsApiCall, logoutApiCall, refreshTokenApiCall } from '../server-actions' +import { changePasswordApiCall, convertAuthTokenApiCall, forgotPasswordApiCall, getUserDetailsApiCall, loginApiCall, logoutApiCall, multiFactorApiCall, passwordRecoveryApiCall, refreshTokenApiCall, validateTokenApiCall } from '../server-actions' import { getAccessToken, getRefreshToken, @@ -14,13 +14,22 @@ import { setRefreshToken as setRefreshTokenStorage, setUserDetails as setUserDetailsStorage, } from '../storageHelpers' +import { LoginRequest } from '../models/login-request' +import { LoginResponse } from '../models/login-response' +import { MultiFactorLoginRequest } from '../models/multi-factor-login-request' export interface AuthenticationContextState { isLoading: boolean user: User | null - login: () => void + login: (loginRequest: LoginRequest) => Promise + externalLogin: () => void + multiFactorLogin: (multiFactorRequest: MultiFactorLoginRequest) => void + forgotPassword: (email: string) => Promise, + passwordRecovery: (token: string, password: string) => Promise, + changePassword: (password: string) => Promise logout: () => void accessToken: string | null + error: string | null } export const AuthenticationContext = createContext(undefined) @@ -70,6 +79,8 @@ export const AuthenticationProvider = (props: AuthenticationProviderProps) => { const [user, setUser] = useState(getUserDetails()) const [path, setPath] = useState(window.location.pathname) const [isLoading, setIsLoading] = useState(true) + const [isRefreshingToken, setIsRefreshingToken] = useState(false) + const [error, setError] = useState(null) const setNewPath = () => setPath(window.location.pathname) @@ -86,27 +97,69 @@ export const AuthenticationProvider = (props: AuthenticationProviderProps) => { } }, [path]) + useEffect(() => { - const checkTokenExpiry = async () => { - setIsLoading(true) - if (isTokenAboutToExpire(accessToken)) { - await refreshAccessToken() - } + const validateAndRefreshToken = async () => { + setIsLoading(true); + try { + if (accessToken) { + let accessTokenLocal = accessToken + const isValid = await validateTokenApiCall(props.authServerUrl, accessTokenLocal); + + if (!isValid) { + const response = await refreshAccessToken(); + if (!response?.accessTokenResponse) + throw new Error() - const intervalId = setInterval(async () => { - if (isTokenAboutToExpire(accessToken)) { - await refreshAccessToken() + accessTokenLocal = response.accessTokenResponse + } + + const userDetails = await getUserDetailsApiCall(props.authServerUrl, accessTokenLocal); + setUser(userDetails); } - }, 5000) - setIsLoading(false) - return () => clearInterval(intervalId) + setIsLoading(false); + } catch (err) { + setError('Failed to validate or refresh token'); + setIsLoading(false); + } + }; + + validateAndRefreshToken(); + }, []); + + useEffect(() => { + const intervalId = setInterval(async () => { + const accToken = getAccessToken() + if (accToken && isTokenAboutToExpire(accToken) && !isRefreshingToken) { + setIsRefreshingToken(true) + } + }, TOKEN_EXPIRY_THRESHOLD); + + return () => clearInterval(intervalId); + }, [isRefreshingToken]) + + useEffect(() => { + const refreshToken = async () => { + try { + const response = await refreshAccessToken(); + if (!response?.accessTokenResponse) + throw new Error() + + const userDetails = await getUserDetailsApiCall(props.authServerUrl, response.accessTokenResponse); + setUser(userDetails); + } catch (err) { + setError('Failed to refresh access token'); + logoutLocal(); + } + setIsRefreshingToken(false) } - checkTokenExpiry() - }, [accessToken, refreshToken]) + if (isRefreshingToken) + refreshToken() + }, [isRefreshingToken, props.authServerUrl]) - const login = () => { + const externalLogin = () => { window.location.replace( `${props.authServerUrl}/Login?RedirectUrl=${window.location.origin}&CallbackUri=${props.snAuthConfiguration.callbackUri}`, ) @@ -119,20 +172,18 @@ export const AuthenticationProvider = (props: AuthenticationProviderProps) => { try { if (authToken) { - const { accessToken: accessTokenResponse, refreshToken: refreshTokenRepsonse } = await convertAuthTokenApiCall( + const { accessToken: accessTokenResponse, refreshToken: refreshTokenResponse } = await convertAuthTokenApiCall( props.authServerUrl, authToken, ) - setAccessToken(accessTokenResponse) - setRefreshToken(refreshTokenRepsonse) - setAccessTokenStorage(accessTokenResponse) - setRefreshTokenStorage(refreshTokenRepsonse) + setAccessAndRefreshToken(accessTokenResponse, refreshTokenResponse) const user = await getUserDetailsApiCall(props.authServerUrl, accessTokenResponse) setUser(user) setUserDetailsStorage(user) } } catch (e) { + setError(e); console.error(e) } finally { setIsLoading(false) @@ -147,13 +198,13 @@ export const AuthenticationProvider = (props: AuthenticationProviderProps) => { props.authServerUrl, refreshToken, ) - setAccessToken(accessTokenResponse) - setRefreshToken(refreshTokenResponse) + setAccessAndRefreshToken(accessTokenResponse, refreshTokenResponse) - setAccessTokenStorage(accessTokenResponse) - setRefreshTokenStorage(refreshTokenResponse) + return { accessTokenResponse, refreshTokenResponse } } catch (e) { console.error(e) + setError("Failed to refresh token") + logoutLocal() } finally { setIsLoading(false) } @@ -174,6 +225,69 @@ export const AuthenticationProvider = (props: AuthenticationProviderProps) => { else logoutLocal() } + const login = async (loginRequest: LoginRequest): Promise => { + try { + const response = await loginApiCall(props.authServerUrl, loginRequest) + + if (!response.multiFactorRequired && response.accessToken && response.refreshToken) { + setAccessAndRefreshToken(response.accessToken, response.refreshToken) + + const user = await getUserDetailsApiCall(props.authServerUrl, response.accessToken) + setUser(user) + setUserDetailsStorage(user) + + return response + } + else { + throw new Error() + } + } + catch (e) { + console.log("Error during login.") + + removeAccessToken() + removeRefreshToken() + + throw e; + } + } + + const multiFactorLogin = async (multiFactorRequest: MultiFactorLoginRequest): Promise => { + try { + const response = await multiFactorApiCall(props.authServerUrl, multiFactorRequest) + + if (response.accessToken && response.refreshToken) { + setAccessAndRefreshToken(response.accessToken, response.refreshToken) + + return response; + } + else { + throw new Error(); + } + } + catch (e) { + console.log("Error during multi-factor validation.") + + removeAccessToken() + removeRefreshToken() + + throw e; + } + } + + const forgotPassword = async (email: string) => { + await forgotPasswordApiCall(props.authServerUrl, { email }) + } + + const passwordRecovery = async (token: string, password: string) => { + await passwordRecoveryApiCall(props.authServerUrl, { token, password }) + } + + const changePassword = async (password: string) => { + if (accessToken) + changePasswordApiCall(props.authServerUrl, accessToken, { password }) + } + const logoutLocal = () => { setAccessToken(null) setRefreshToken(null) @@ -186,14 +300,28 @@ export const AuthenticationProvider = (props: AuthenticationProviderProps) => { window.location.replace('/') } + const setAccessAndRefreshToken = (accessToken: string, refreshToken: string) => { + setAccessToken(accessToken) + setRefreshToken(refreshToken) + + setAccessTokenStorage(accessToken) + setRefreshTokenStorage(refreshToken) + } + return ( {props.children} diff --git a/packages/sn-auth-react/src/models/change-password-request.ts b/packages/sn-auth-react/src/models/change-password-request.ts new file mode 100644 index 000000000..ba2c4d4fc --- /dev/null +++ b/packages/sn-auth-react/src/models/change-password-request.ts @@ -0,0 +1,3 @@ +export interface ChangePasswordRequest { + password: string; +} \ No newline at end of file diff --git a/packages/sn-auth-react/src/models/forgotten-password-request.ts b/packages/sn-auth-react/src/models/forgotten-password-request.ts new file mode 100644 index 000000000..e53a2c2f3 --- /dev/null +++ b/packages/sn-auth-react/src/models/forgotten-password-request.ts @@ -0,0 +1,4 @@ +export interface ForgottenPasswordRequest { + email: string; + passwordRecoveryUrl?: string; +} \ No newline at end of file diff --git a/packages/sn-auth-react/src/models/multi-factor-login-request.ts b/packages/sn-auth-react/src/models/multi-factor-login-request.ts new file mode 100644 index 000000000..aa1b4c798 --- /dev/null +++ b/packages/sn-auth-react/src/models/multi-factor-login-request.ts @@ -0,0 +1,4 @@ +export interface MultiFactorLoginRequest { + multiFactorAuthToken: string; + multiFactorCode: string; +} \ No newline at end of file diff --git a/packages/sn-auth-react/src/models/password-recovery-request.ts b/packages/sn-auth-react/src/models/password-recovery-request.ts new file mode 100644 index 000000000..c96df7bd5 --- /dev/null +++ b/packages/sn-auth-react/src/models/password-recovery-request.ts @@ -0,0 +1,4 @@ +export interface PasswordRecoveryRequest { + password: string; + token: string; +} \ No newline at end of file diff --git a/packages/sn-auth-react/src/server-actions.ts b/packages/sn-auth-react/src/server-actions.ts index 66d39d3bc..432a3134a 100644 --- a/packages/sn-auth-react/src/server-actions.ts +++ b/packages/sn-auth-react/src/server-actions.ts @@ -1,3 +1,9 @@ +import { ChangePasswordRequest } from './models/change-password-request'; +import { ForgottenPasswordRequest } from './models/forgotten-password-request'; +import { LoginRequest } from './models/login-request'; +import { LoginResponse } from './models/login-response'; +import { MultiFactorLoginRequest } from './models/multi-factor-login-request'; +import { PasswordRecoveryRequest } from './models/password-recovery-request'; import { User } from './models/user' export async function convertAuthTokenApiCall( @@ -75,6 +81,125 @@ export async function logoutApiCall(server: string, accessToken: string): Promis } } +export async function loginApiCall(server: string, loginRequest: LoginRequest): Promise { + try { + const response = await fetch(`${server}/api/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(loginRequest) + }) + + if (response.ok) { + return await response.json() + } else { + throw new Error(`Error during login: ${response.statusText}`) + } + } catch (error) { + console.error('Error:', error) + throw error + } +} + +export async function forgotPasswordApiCall(server: string, passwordRequest: ForgottenPasswordRequest): Promise { + try { + const response = await fetch(`${server}/api/auth/forgotten-password`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(passwordRequest) + }) + + if (!response.ok) { + throw new Error(`Error during request password recovery email: ${response.statusText}`) + } + } catch (error) { + console.error('Error:', error) + throw error + } +} + +export async function passwordRecoveryApiCall(server: string, passwordRequest: PasswordRecoveryRequest): Promise { + try { + const response = await fetch(`${server}/api/auth/password-recovery`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(passwordRequest) + }) + + if (!response.ok) { + throw new Error(`Error during resetting password: ${response.statusText}`) + } + } catch (error) { + console.error('Error:', error) + throw error + } +} + +export async function changePasswordApiCall(server: string, accessToken: string, passwordRequest: ChangePasswordRequest): Promise { + try { + const response = await fetch(`${server}/api/auth/password-recovery`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(passwordRequest) + }) + + if (!response.ok) { + throw new Error(`Error during changing password: ${response.statusText}`) + } + } catch (error) { + console.error('Error:', error) + throw error + } +} + +export async function multiFactorApiCall(server: string, loginRequest: MultiFactorLoginRequest): Promise { + try { + const response = await fetch(`${server}/api/auth/login/multi-factor`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(loginRequest) + }) + + if (response.ok) { + return await response.json() + } else { + throw new Error(`Error during mfa valiadation: ${response.statusText}`) + } + } catch (error) { + console.error('Error:', error) + throw error + } +} + +export async function validateTokenApiCall(server: string, accessToken: string): Promise { + try { + const response = await fetch(`${server}/api/user`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (response.ok) + return true + } catch (error) { + console.error('Error:', error) + } + + return false +} + export async function getUserDetailsApiCall(server: string, accessToken: string): Promise { try { const response = await fetch(`${server}/api/user`, { @@ -99,7 +224,7 @@ export async function getUserDetailsApiCall(server: string, accessToken: string) Id: user.id, LoginName: user.loginName, Path: user.path - } + } } else { throw new Error(`Error during logout: ${response.statusText}`) } @@ -107,4 +232,4 @@ export async function getUserDetailsApiCall(server: string, accessToken: string) console.error('Error:', error) throw error } -} +} \ No newline at end of file diff --git a/packages/sn-auth-react/test/authentication-provider.test.tsx b/packages/sn-auth-react/test/authentication-provider.test.tsx index 4aee1d795..79690c3a6 100644 --- a/packages/sn-auth-react/test/authentication-provider.test.tsx +++ b/packages/sn-auth-react/test/authentication-provider.test.tsx @@ -74,9 +74,9 @@ describe('AuthenticationProvider', () => { beforeEach(() => { jest.clearAllMocks() - ;(getAccessToken as jest.Mock).mockReturnValue('mockAccessToken') - ;(getRefreshToken as jest.Mock).mockReturnValue('mockRefreshToken') - ;(getUserDetails as jest.Mock).mockReturnValue(mockUser) + ; (getAccessToken as jest.Mock).mockReturnValue('mockAccessToken') + ; (getRefreshToken as jest.Mock).mockReturnValue('mockRefreshToken') + ; (getUserDetails as jest.Mock).mockReturnValue(mockUser) originalLocation = window.location @@ -104,7 +104,7 @@ describe('AuthenticationProvider', () => { test('calls login function and redirects to the correct URL', async () => { setup( - {(c) => } + {(c) => } , ) @@ -123,7 +123,7 @@ describe('AuthenticationProvider', () => { {(c) => } , ) - ;(logoutApiCall as jest.Mock).mockResolvedValueOnce(null) + ; (logoutApiCall as jest.Mock).mockResolvedValueOnce(null) await act(async () => { screen.getByText('Logout').click() @@ -136,7 +136,7 @@ describe('AuthenticationProvider', () => { }) test('refreshes access token when about to expire', async () => { - ;(refreshTokenApiCall as jest.Mock).mockResolvedValue({ + ; (refreshTokenApiCall as jest.Mock).mockResolvedValue({ accessToken: 'newAccessToken', refreshToken: 'newRefreshToken', }) @@ -163,11 +163,11 @@ describe('AuthenticationProvider', () => { replace: jest.fn(), }, }) - ;(convertAuthTokenApiCall as jest.Mock).mockResolvedValue({ - accessToken: 'newAccessToken', - refreshToken: 'newRefreshToken', - }) - ;(getUserDetailsApiCall as jest.Mock).mockResolvedValue(mockUser) + ; (convertAuthTokenApiCall as jest.Mock).mockResolvedValue({ + accessToken: 'newAccessToken', + refreshToken: 'newRefreshToken', + }) + ; (getUserDetailsApiCall as jest.Mock).mockResolvedValue(mockUser) await act(async () => { setup() diff --git a/packages/sn-editor-react/src/components/controls/table-control.tsx b/packages/sn-editor-react/src/components/controls/table-control.tsx index 240045b08..0e9a990cf 100644 --- a/packages/sn-editor-react/src/components/controls/table-control.tsx +++ b/packages/sn-editor-react/src/components/controls/table-control.tsx @@ -89,7 +89,6 @@ export const TableControl: FC = ({ editor, buttonProps }) => onChange={(ev) => setRows(parseInt(ev.target.value, 10))} className={classes.textField} /> -