From 31c2ef52790dc55cc2de084366c898561976561c Mon Sep 17 00:00:00 2001 From: Matthew Penner Date: Sat, 17 Jul 2021 12:48:14 -0600 Subject: [PATCH] webauthn: update login flow to support other 2fa methods --- .../Auth/AbstractLoginController.php | 9 +-- .../Auth/LoginCheckpointController.php | 3 +- app/Http/Controllers/Auth/LoginController.php | 14 ++-- .../scripts/api/account/webauthn/deleteKey.ts | 9 +++ .../api/account/webauthn/getWebauthn.ts | 23 ++++++ .../scripts/api/account/webauthn/login.ts | 51 +++++++++++++ .../api/account/webauthn/registerKey.ts | 73 +++++++++++++++++++ resources/scripts/api/auth/login.ts | 15 ++-- resources/scripts/api/auth/loginCheckpoint.ts | 4 +- .../components/auth/LoginContainer.tsx | 22 +++++- .../components/auth/LoginFormContainer.tsx | 58 ++++++++++----- .../auth/LoginKeyCheckpointContainer.tsx | 11 +++ .../scripts/routers/AuthenticationRouter.tsx | 4 +- 13 files changed, 255 insertions(+), 41 deletions(-) create mode 100644 resources/scripts/api/account/webauthn/deleteKey.ts create mode 100644 resources/scripts/api/account/webauthn/getWebauthn.ts create mode 100644 resources/scripts/api/account/webauthn/login.ts create mode 100644 resources/scripts/api/account/webauthn/registerKey.ts create mode 100644 resources/scripts/components/auth/LoginKeyCheckpointContainer.tsx diff --git a/app/Http/Controllers/Auth/AbstractLoginController.php b/app/Http/Controllers/Auth/AbstractLoginController.php index e9951fc32..b490d6036 100644 --- a/app/Http/Controllers/Auth/AbstractLoginController.php +++ b/app/Http/Controllers/Auth/AbstractLoginController.php @@ -90,11 +90,10 @@ abstract class AbstractLoginController extends Controller $this->auth->guard()->login($user, true); return new JsonResponse([ - 'data' => [ - 'complete' => true, - 'intended' => $this->redirectPath(), - 'user' => $user->toReactObject(), - ], + 'complete' => true, + 'methods' => [], + 'intended' => $this->redirectPath(), + 'user' => $user->toReactObject(), ]); } diff --git a/app/Http/Controllers/Auth/LoginCheckpointController.php b/app/Http/Controllers/Auth/LoginCheckpointController.php index 25c5c5ebf..dffa79998 100644 --- a/app/Http/Controllers/Auth/LoginCheckpointController.php +++ b/app/Http/Controllers/Auth/LoginCheckpointController.php @@ -4,7 +4,6 @@ namespace Pterodactyl\Http\Controllers\Auth; use Pterodactyl\Models\User; use Illuminate\Auth\AuthManager; -use Illuminate\Http\JsonResponse; use PragmaRX\Google2FA\Google2FA; use Illuminate\Contracts\Config\Repository; use Illuminate\Contracts\Encryption\Encrypter; @@ -48,7 +47,7 @@ class LoginCheckpointController extends AbstractLoginController * @throws \PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException * @throws \Illuminate\Validation\ValidationException */ - public function __invoke(LoginCheckpointRequest $request): JsonResponse + public function __invoke(LoginCheckpointRequest $request) { if ($this->hasTooManyLoginAttempts($request)) { $this->sendLockoutResponse($request); diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 7942a27b1..0e676bbb7 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -17,11 +17,11 @@ use Pterodactyl\Exceptions\Repository\RecordNotFoundException; class LoginController extends AbstractLoginController { - /** - * @var string - */ private const SESSION_PUBLICKEY_REQUEST = 'webauthn.publicKeyRequest'; + private const METHOD_TOTP = 'totp'; + private const METHOD_WEBAUTHN = 'webauthn'; + private CacheRepository $cache; private UserRepositoryInterface $repository; private ViewFactory $view; @@ -61,7 +61,7 @@ class LoginController extends AbstractLoginController * @throws \Pterodactyl\Exceptions\DisplayException * @throws \Illuminate\Validation\ValidationException */ - public function login(Request $request): JsonResponse + public function login(Request $request) { $username = $request->input('user'); $useColumn = $this->getField($username); @@ -99,9 +99,9 @@ class LoginController extends AbstractLoginController $request->session()->put(self::SESSION_PUBLICKEY_REQUEST, $publicKey); $request->session()->save(); - $methods = ['webauthn']; + $methods = [ self::METHOD_WEBAUTHN ]; if ($user->use_totp) { - $methods[] = 'totp'; + $methods[] = self::METHOD_TOTP; } return new JsonResponse([ @@ -118,7 +118,7 @@ class LoginController extends AbstractLoginController return new JsonResponse([ 'complete' => false, - 'methods' => ['totp'], + 'methods' => [ self::METHOD_TOTP ], 'confirmation_token' => $token, ]); } diff --git a/resources/scripts/api/account/webauthn/deleteKey.ts b/resources/scripts/api/account/webauthn/deleteKey.ts new file mode 100644 index 000000000..b7abb162a --- /dev/null +++ b/resources/scripts/api/account/webauthn/deleteKey.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; + +export default (id: number): Promise => { + return new Promise((resolve, reject) => { + http.delete(`/api/client/account/webauthn/${id}`) + .then(() => resolve()) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/account/webauthn/getWebauthn.ts b/resources/scripts/api/account/webauthn/getWebauthn.ts new file mode 100644 index 000000000..b487c04f5 --- /dev/null +++ b/resources/scripts/api/account/webauthn/getWebauthn.ts @@ -0,0 +1,23 @@ +import http from '@/api/http'; + +export interface Key { + id: number; + name: string; + createdAt: Date; + lastUsedAt: Date; +} + +export const rawDataToKey = (data: any): Key => ({ + id: data.id, + name: data.name, + createdAt: new Date(data.created_at), + lastUsedAt: new Date(data.last_used_at) || new Date(), +}); + +export default (): Promise => { + return new Promise((resolve, reject) => { + http.get('/api/client/account/webauthn') + .then(({ data }) => resolve((data.data || []).map((d: any) => rawDataToKey(d.attributes)))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/account/webauthn/login.ts b/resources/scripts/api/account/webauthn/login.ts new file mode 100644 index 000000000..a7512eb8f --- /dev/null +++ b/resources/scripts/api/account/webauthn/login.ts @@ -0,0 +1,51 @@ +import http from '@/api/http'; +import { LoginResponse } from '@/api/auth/login'; +import { base64Decode, bufferDecode, bufferEncode, decodeCredentials } from '@/api/account/webauthn/registerKey'; + +export default (token: string, publicKey: PublicKeyCredentialRequestOptions): Promise => { + return new Promise((resolve, reject) => { + console.log(token); + console.log(publicKey); + const publicKeyCredential = Object.assign({}, publicKey); + + publicKeyCredential.challenge = bufferDecode(base64Decode(publicKey.challenge.toString())); + if (publicKey.allowCredentials) { + publicKeyCredential.allowCredentials = decodeCredentials(publicKey.allowCredentials); + } + + navigator.credentials.get({ + publicKey: publicKeyCredential, + }).then((c) => { + if (c === null) { + return; + } + const credential = c as PublicKeyCredential; + const response = credential.response as AuthenticatorAssertionResponse; + + const data = { + confirmation_token: token, + + data: JSON.stringify({ + id: credential.id, + type: credential.type, + rawId: bufferEncode(credential.rawId), + + response: { + authenticatorData: bufferEncode(response.authenticatorData), + clientDataJSON: bufferEncode(response.clientDataJSON), + signature: bufferEncode(response.signature), + userHandle: response.userHandle ? bufferEncode(response.userHandle) : null, + }, + }), + }; + console.log(data); + + http.post('/auth/login/checkpoint/key', data).then(response => { + return resolve({ + complete: response.data.complete, + intended: response.data.data?.intended || undefined, + }); + }).catch(reject); + }).catch(reject); + }); +}; diff --git a/resources/scripts/api/account/webauthn/registerKey.ts b/resources/scripts/api/account/webauthn/registerKey.ts new file mode 100644 index 000000000..878b78d85 --- /dev/null +++ b/resources/scripts/api/account/webauthn/registerKey.ts @@ -0,0 +1,73 @@ +import http from '@/api/http'; +import { Key, rawDataToKey } from '@/api/account/webauthn/getWebauthn'; + +export const base64Decode = (input: string): string => { + input = input.replace(/-/g, '+').replace(/_/g, '/'); + const pad = input.length % 4; + if (pad) { + if (pad === 1) { + throw new Error('InvalidLengthError: Input base64url string is the wrong length to determine padding'); + } + input += new Array(5 - pad).join('='); + } + return input; +}; + +export const bufferDecode = (value: string): ArrayBuffer => { + return Uint8Array.from(window.atob(value), c => c.charCodeAt(0)); +}; + +export const bufferEncode = (value: ArrayBuffer): string => { + // @ts-ignore + return window.btoa(String.fromCharCode.apply(null, new Uint8Array(value))); +}; + +export const decodeCredentials = (credentials: PublicKeyCredentialDescriptor[]) => { + return credentials.map(c => { + return { + id: bufferDecode(base64Decode(c.id.toString())), + type: c.type, + transports: c.transports, + }; + }); +}; + +export default (name: string): Promise => { + return new Promise((resolve, reject) => { + http.get('/api/client/account/webauthn/register').then((res) => { + const publicKey = res.data.public_key; + const publicKeyCredential = Object.assign({}, publicKey); + + publicKeyCredential.user.id = bufferDecode(publicKey.user.id); + publicKeyCredential.challenge = bufferDecode(base64Decode(publicKey.challenge)); + if (publicKey.excludeCredentials) { + publicKeyCredential.excludeCredentials = decodeCredentials(publicKey.excludeCredentials); + } + + return navigator.credentials.create({ + publicKey: publicKeyCredential, + }); + }).then((c) => { + if (c === null) { + return; + } + const credential = c as PublicKeyCredential; + const response = credential.response as AuthenticatorAttestationResponse; + + http.post('/api/client/account/webauthn/register', { + name: name, + + register: JSON.stringify({ + id: credential.id, + type: credential.type, + rawId: bufferEncode(credential.rawId), + + response: { + attestationObject: bufferEncode(response.attestationObject), + clientDataJSON: bufferEncode(response.clientDataJSON), + }, + }), + }).then(({ data }) => resolve(rawDataToKey(data.attributes))).catch(reject); + }).catch(reject); + }); +}; diff --git a/resources/scripts/api/auth/login.ts b/resources/scripts/api/auth/login.ts index af2f5faa3..0bd5d82b9 100644 --- a/resources/scripts/api/auth/login.ts +++ b/resources/scripts/api/auth/login.ts @@ -1,9 +1,11 @@ import http from '@/api/http'; export interface LoginResponse { + methods?: string[]; complete: boolean; intended?: string; confirmationToken?: string; + publicKey?: any; } export interface LoginData { @@ -19,15 +21,18 @@ export default ({ username, password, recaptchaData }: LoginData): Promise { - if (!(response.data instanceof Object)) { + .then(({ data }) => { + if (!(data instanceof Object)) { return reject(new Error('An error occurred while processing the login request.')); } return resolve({ - complete: response.data.data.complete, - intended: response.data.data.intended || undefined, - confirmationToken: response.data.data.confirmation_token || undefined, + methods: data.methods, + complete: data.complete, + intended: data.intended || undefined, + confirmationToken: data.confirmation_token || undefined, + // eslint-disable-next-line camelcase + publicKey: data.webauthn?.public_key || undefined, }); }) .catch(reject); diff --git a/resources/scripts/api/auth/loginCheckpoint.ts b/resources/scripts/api/auth/loginCheckpoint.ts index 2d139fa52..e5314f273 100644 --- a/resources/scripts/api/auth/loginCheckpoint.ts +++ b/resources/scripts/api/auth/loginCheckpoint.ts @@ -9,8 +9,8 @@ export default (token: string, code: string, recoveryToken?: string): Promise 0) ? recoveryToken : undefined, }) .then(response => resolve({ - complete: response.data.data.complete, - intended: response.data.data.intended || undefined, + complete: response.data.complete, + intended: response.data.intended || undefined, })) .catch(reject); }); diff --git a/resources/scripts/components/auth/LoginContainer.tsx b/resources/scripts/components/auth/LoginContainer.tsx index 8157111ee..e627da964 100644 --- a/resources/scripts/components/auth/LoginContainer.tsx +++ b/resources/scripts/components/auth/LoginContainer.tsx @@ -39,19 +39,37 @@ const LoginContainer = ({ history }: RouteComponentProps) => { setSubmitting(false); clearAndAddHttpError({ error }); }); - return; } login({ ...values, recaptchaData: token }) .then(response => { + console.log('wow!'); + console.log(response); if (response.complete) { + console.log(`Redirecting to: ${response.intended || '/'}`); // @ts-ignore window.location = response.intended || '/'; return; } - history.replace('/auth/login/checkpoint', { token: response.confirmationToken }); + if (response.methods?.includes('webauthn')) { + console.log('Redirecting to: /auth/login/key'); + history.replace('/auth/login/key', { + token: response.confirmationToken, + publicKey: response.publicKey, + hasTotp: response.methods.includes('totp'), + }); + return; + } + + if (response.methods?.includes('totp')) { + console.log('/auth/login/checkpoint'); + history.replace('/auth/login/checkpoint', { token: response.confirmationToken }); + return; + } + + console.log('huh?'); }) .catch(error => { console.error(error); diff --git a/resources/scripts/components/auth/LoginFormContainer.tsx b/resources/scripts/components/auth/LoginFormContainer.tsx index e54a50b75..750eafed3 100644 --- a/resources/scripts/components/auth/LoginFormContainer.tsx +++ b/resources/scripts/components/auth/LoginFormContainer.tsx @@ -5,11 +5,7 @@ import { breakpoint } from '@/theme'; import FlashMessageRender from '@/components/FlashMessageRender'; import tw from 'twin.macro'; -type Props = React.DetailedHTMLProps, HTMLFormElement> & { - title?: string; -} - -const Container = styled.div` +const Wrapper = styled.div` ${breakpoint('sm')` ${tw`w-4/5 mx-auto`} `}; @@ -28,24 +24,26 @@ const Container = styled.div` `}; `; -export default forwardRef(({ title, ...props }, ref) => ( - +const Inner = ({ children }: { children: React.ReactNode }) => ( +
+
+ +
+
+ {children} +
+
+); + +const Container = ({ title, children }: { title?: string, children: React.ReactNode }) => ( + {title &&

{title}

} -
-
-
- -
-
- {props.children} -
-
-
+ {children}

© 2015 - {(new Date()).getFullYear()}  (({ title, ...props }, ref) => Pterodactyl Software

+
+); + +type FormContainerProps = React.DetailedHTMLProps, HTMLFormElement> & { + title?: string; +} + +const FormContainer = forwardRef(({ title, ...props }, ref) => ( + +
+ {props.children} +
)); + +type DivContainerProps = React.DetailedHTMLProps, HTMLDivElement> & { + title?: string; +} + +export const DivContainer = ({ title, ...props }: DivContainerProps) => ( + +
+ {props.children} +
+
+); + +export default FormContainer; diff --git a/resources/scripts/components/auth/LoginKeyCheckpointContainer.tsx b/resources/scripts/components/auth/LoginKeyCheckpointContainer.tsx new file mode 100644 index 000000000..4810d8d69 --- /dev/null +++ b/resources/scripts/components/auth/LoginKeyCheckpointContainer.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import tw from 'twin.macro'; +import { DivContainer as LoginFormContainer } from '@/components/auth/LoginFormContainer'; + +const LoginKeyCheckpointContainer = () => { + return ( + + ); +}; + +export default LoginKeyCheckpointContainer; diff --git a/resources/scripts/routers/AuthenticationRouter.tsx b/resources/scripts/routers/AuthenticationRouter.tsx index f49263108..49290746e 100644 --- a/resources/scripts/routers/AuthenticationRouter.tsx +++ b/resources/scripts/routers/AuthenticationRouter.tsx @@ -1,9 +1,10 @@ import React from 'react'; import { Route, RouteComponentProps, Switch } from 'react-router-dom'; import LoginContainer from '@/components/auth/LoginContainer'; +import LoginCheckpointContainer from '@/components/auth/LoginCheckpointContainer'; +import LoginKeyCheckpointContainer from '@/components/auth/LoginKeyCheckpointContainer'; import ForgotPasswordContainer from '@/components/auth/ForgotPasswordContainer'; import ResetPasswordContainer from '@/components/auth/ResetPasswordContainer'; -import LoginCheckpointContainer from '@/components/auth/LoginCheckpointContainer'; import { NotFound } from '@/components/elements/ScreenBlock'; export default ({ location, history, match }: RouteComponentProps) => ( @@ -11,6 +12,7 @@ export default ({ location, history, match }: RouteComponentProps) => ( +