webauthn: update login flow to support other 2fa methods

This commit is contained in:
Matthew Penner 2021-07-17 12:48:14 -06:00
parent 42a3e740ba
commit 31c2ef5279
13 changed files with 255 additions and 41 deletions

View file

@ -90,11 +90,10 @@ abstract class AbstractLoginController extends Controller
$this->auth->guard()->login($user, true); $this->auth->guard()->login($user, true);
return new JsonResponse([ return new JsonResponse([
'data' => [ 'complete' => true,
'complete' => true, 'methods' => [],
'intended' => $this->redirectPath(), 'intended' => $this->redirectPath(),
'user' => $user->toReactObject(), 'user' => $user->toReactObject(),
],
]); ]);
} }

View file

@ -4,7 +4,6 @@ namespace Pterodactyl\Http\Controllers\Auth;
use Pterodactyl\Models\User; use Pterodactyl\Models\User;
use Illuminate\Auth\AuthManager; use Illuminate\Auth\AuthManager;
use Illuminate\Http\JsonResponse;
use PragmaRX\Google2FA\Google2FA; use PragmaRX\Google2FA\Google2FA;
use Illuminate\Contracts\Config\Repository; use Illuminate\Contracts\Config\Repository;
use Illuminate\Contracts\Encryption\Encrypter; use Illuminate\Contracts\Encryption\Encrypter;
@ -48,7 +47,7 @@ class LoginCheckpointController extends AbstractLoginController
* @throws \PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException * @throws \PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException
* @throws \Illuminate\Validation\ValidationException * @throws \Illuminate\Validation\ValidationException
*/ */
public function __invoke(LoginCheckpointRequest $request): JsonResponse public function __invoke(LoginCheckpointRequest $request)
{ {
if ($this->hasTooManyLoginAttempts($request)) { if ($this->hasTooManyLoginAttempts($request)) {
$this->sendLockoutResponse($request); $this->sendLockoutResponse($request);

View file

@ -17,11 +17,11 @@ use Pterodactyl\Exceptions\Repository\RecordNotFoundException;
class LoginController extends AbstractLoginController class LoginController extends AbstractLoginController
{ {
/**
* @var string
*/
private const SESSION_PUBLICKEY_REQUEST = 'webauthn.publicKeyRequest'; private const SESSION_PUBLICKEY_REQUEST = 'webauthn.publicKeyRequest';
private const METHOD_TOTP = 'totp';
private const METHOD_WEBAUTHN = 'webauthn';
private CacheRepository $cache; private CacheRepository $cache;
private UserRepositoryInterface $repository; private UserRepositoryInterface $repository;
private ViewFactory $view; private ViewFactory $view;
@ -61,7 +61,7 @@ class LoginController extends AbstractLoginController
* @throws \Pterodactyl\Exceptions\DisplayException * @throws \Pterodactyl\Exceptions\DisplayException
* @throws \Illuminate\Validation\ValidationException * @throws \Illuminate\Validation\ValidationException
*/ */
public function login(Request $request): JsonResponse public function login(Request $request)
{ {
$username = $request->input('user'); $username = $request->input('user');
$useColumn = $this->getField($username); $useColumn = $this->getField($username);
@ -99,9 +99,9 @@ class LoginController extends AbstractLoginController
$request->session()->put(self::SESSION_PUBLICKEY_REQUEST, $publicKey); $request->session()->put(self::SESSION_PUBLICKEY_REQUEST, $publicKey);
$request->session()->save(); $request->session()->save();
$methods = ['webauthn']; $methods = [ self::METHOD_WEBAUTHN ];
if ($user->use_totp) { if ($user->use_totp) {
$methods[] = 'totp'; $methods[] = self::METHOD_TOTP;
} }
return new JsonResponse([ return new JsonResponse([
@ -118,7 +118,7 @@ class LoginController extends AbstractLoginController
return new JsonResponse([ return new JsonResponse([
'complete' => false, 'complete' => false,
'methods' => ['totp'], 'methods' => [ self::METHOD_TOTP ],
'confirmation_token' => $token, 'confirmation_token' => $token,
]); ]);
} }

View file

@ -0,0 +1,9 @@
import http from '@/api/http';
export default (id: number): Promise<void> => {
return new Promise((resolve, reject) => {
http.delete(`/api/client/account/webauthn/${id}`)
.then(() => resolve())
.catch(reject);
});
};

View file

@ -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<Key[]> => {
return new Promise((resolve, reject) => {
http.get('/api/client/account/webauthn')
.then(({ data }) => resolve((data.data || []).map((d: any) => rawDataToKey(d.attributes))))
.catch(reject);
});
};

View file

@ -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<LoginResponse> => {
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);
});
};

View file

@ -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<Key> => {
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);
});
};

View file

@ -1,9 +1,11 @@
import http from '@/api/http'; import http from '@/api/http';
export interface LoginResponse { export interface LoginResponse {
methods?: string[];
complete: boolean; complete: boolean;
intended?: string; intended?: string;
confirmationToken?: string; confirmationToken?: string;
publicKey?: any;
} }
export interface LoginData { export interface LoginData {
@ -19,15 +21,18 @@ export default ({ username, password, recaptchaData }: LoginData): Promise<Login
password, password,
'g-recaptcha-response': recaptchaData, 'g-recaptcha-response': recaptchaData,
}) })
.then(response => { .then(({ data }) => {
if (!(response.data instanceof Object)) { if (!(data instanceof Object)) {
return reject(new Error('An error occurred while processing the login request.')); return reject(new Error('An error occurred while processing the login request.'));
} }
return resolve({ return resolve({
complete: response.data.data.complete, methods: data.methods,
intended: response.data.data.intended || undefined, complete: data.complete,
confirmationToken: response.data.data.confirmation_token || undefined, intended: data.intended || undefined,
confirmationToken: data.confirmation_token || undefined,
// eslint-disable-next-line camelcase
publicKey: data.webauthn?.public_key || undefined,
}); });
}) })
.catch(reject); .catch(reject);

View file

@ -9,8 +9,8 @@ export default (token: string, code: string, recoveryToken?: string): Promise<Lo
recovery_token: (recoveryToken && recoveryToken.length > 0) ? recoveryToken : undefined, recovery_token: (recoveryToken && recoveryToken.length > 0) ? recoveryToken : undefined,
}) })
.then(response => resolve({ .then(response => resolve({
complete: response.data.data.complete, complete: response.data.complete,
intended: response.data.data.intended || undefined, intended: response.data.intended || undefined,
})) }))
.catch(reject); .catch(reject);
}); });

View file

@ -39,19 +39,37 @@ const LoginContainer = ({ history }: RouteComponentProps) => {
setSubmitting(false); setSubmitting(false);
clearAndAddHttpError({ error }); clearAndAddHttpError({ error });
}); });
return; return;
} }
login({ ...values, recaptchaData: token }) login({ ...values, recaptchaData: token })
.then(response => { .then(response => {
console.log('wow!');
console.log(response);
if (response.complete) { if (response.complete) {
console.log(`Redirecting to: ${response.intended || '/'}`);
// @ts-ignore // @ts-ignore
window.location = response.intended || '/'; window.location = response.intended || '/';
return; 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 => { .catch(error => {
console.error(error); console.error(error);

View file

@ -5,11 +5,7 @@ import { breakpoint } from '@/theme';
import FlashMessageRender from '@/components/FlashMessageRender'; import FlashMessageRender from '@/components/FlashMessageRender';
import tw from 'twin.macro'; import tw from 'twin.macro';
type Props = React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & { const Wrapper = styled.div`
title?: string;
}
const Container = styled.div`
${breakpoint('sm')` ${breakpoint('sm')`
${tw`w-4/5 mx-auto`} ${tw`w-4/5 mx-auto`}
`}; `};
@ -28,24 +24,26 @@ const Container = styled.div`
`}; `};
`; `;
export default forwardRef<HTMLFormElement, Props>(({ title, ...props }, ref) => ( const Inner = ({ children }: { children: React.ReactNode }) => (
<Container> <div css={tw`md:flex w-full bg-white shadow-lg rounded-lg p-6 md:pl-0 mx-1`}>
<div css={tw`flex-none select-none mb-6 md:mb-0 self-center`}>
<img src={'/assets/svgs/pterodactyl.svg'} css={tw`block w-48 md:w-64 mx-auto`}/>
</div>
<div css={tw`flex-1`}>
{children}
</div>
</div>
);
const Container = ({ title, children }: { title?: string, children: React.ReactNode }) => (
<Wrapper>
{title && {title &&
<h2 css={tw`text-3xl text-center text-neutral-100 font-medium py-4`}> <h2 css={tw`text-3xl text-center text-neutral-100 font-medium py-4`}>
{title} {title}
</h2> </h2>
} }
<FlashMessageRender css={tw`mb-2 px-1`}/> <FlashMessageRender css={tw`mb-2 px-1`}/>
<Form {...props} ref={ref}> {children}
<div css={tw`md:flex w-full bg-white shadow-lg rounded-lg p-6 md:pl-0 mx-1`}>
<div css={tw`flex-none select-none mb-6 md:mb-0 self-center`}>
<img src={'/assets/svgs/pterodactyl.svg'} css={tw`block w-48 md:w-64 mx-auto`}/>
</div>
<div css={tw`flex-1`}>
{props.children}
</div>
</div>
</Form>
<p css={tw`text-center text-neutral-500 text-xs mt-4`}> <p css={tw`text-center text-neutral-500 text-xs mt-4`}>
&copy; 2015 - {(new Date()).getFullYear()}&nbsp; &copy; 2015 - {(new Date()).getFullYear()}&nbsp;
<a <a
@ -57,5 +55,31 @@ export default forwardRef<HTMLFormElement, Props>(({ title, ...props }, ref) =>
Pterodactyl Software Pterodactyl Software
</a> </a>
</p> </p>
</Wrapper>
);
type FormContainerProps = React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
title?: string;
}
const FormContainer = forwardRef<HTMLFormElement, FormContainerProps>(({ title, ...props }, ref) => (
<Container title={title}>
<Form {...props} ref={ref}>
<Inner>{props.children}</Inner>
</Form>
</Container> </Container>
)); ));
type DivContainerProps = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
title?: string;
}
export const DivContainer = ({ title, ...props }: DivContainerProps) => (
<Container title={title}>
<div {...props}>
<Inner>{props.children}</Inner>
</div>
</Container>
);
export default FormContainer;

View file

@ -0,0 +1,11 @@
import React from 'react';
import tw from 'twin.macro';
import { DivContainer as LoginFormContainer } from '@/components/auth/LoginFormContainer';
const LoginKeyCheckpointContainer = () => {
return (
<LoginFormContainer title={'Login to Continue'} css={tw`w-full flex`} />
);
};
export default LoginKeyCheckpointContainer;

View file

@ -1,9 +1,10 @@
import React from 'react'; import React from 'react';
import { Route, RouteComponentProps, Switch } from 'react-router-dom'; import { Route, RouteComponentProps, Switch } from 'react-router-dom';
import LoginContainer from '@/components/auth/LoginContainer'; 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 ForgotPasswordContainer from '@/components/auth/ForgotPasswordContainer';
import ResetPasswordContainer from '@/components/auth/ResetPasswordContainer'; import ResetPasswordContainer from '@/components/auth/ResetPasswordContainer';
import LoginCheckpointContainer from '@/components/auth/LoginCheckpointContainer';
import { NotFound } from '@/components/elements/ScreenBlock'; import { NotFound } from '@/components/elements/ScreenBlock';
export default ({ location, history, match }: RouteComponentProps) => ( export default ({ location, history, match }: RouteComponentProps) => (
@ -11,6 +12,7 @@ export default ({ location, history, match }: RouteComponentProps) => (
<Switch location={location}> <Switch location={location}>
<Route path={`${match.path}/login`} component={LoginContainer} exact/> <Route path={`${match.path}/login`} component={LoginContainer} exact/>
<Route path={`${match.path}/login/checkpoint`} component={LoginCheckpointContainer}/> <Route path={`${match.path}/login/checkpoint`} component={LoginCheckpointContainer}/>
<Route path={`${match.path}/login/key`} component={LoginKeyCheckpointContainer}/>
<Route path={`${match.path}/password`} component={ForgotPasswordContainer} exact/> <Route path={`${match.path}/password`} component={ForgotPasswordContainer} exact/>
<Route path={`${match.path}/password/reset/:token`} component={ResetPasswordContainer}/> <Route path={`${match.path}/password/reset/:token`} component={ResetPasswordContainer}/>
<Route path={`${match.path}/checkpoint`}/> <Route path={`${match.path}/checkpoint`}/>