webauthn: update login flow to support other 2fa methods
This commit is contained in:
parent
42a3e740ba
commit
31c2ef5279
13 changed files with 255 additions and 41 deletions
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
9
resources/scripts/api/account/webauthn/deleteKey.ts
Normal file
9
resources/scripts/api/account/webauthn/deleteKey.ts
Normal 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);
|
||||
});
|
||||
};
|
23
resources/scripts/api/account/webauthn/getWebauthn.ts
Normal file
23
resources/scripts/api/account/webauthn/getWebauthn.ts
Normal 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);
|
||||
});
|
||||
};
|
51
resources/scripts/api/account/webauthn/login.ts
Normal file
51
resources/scripts/api/account/webauthn/login.ts
Normal 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);
|
||||
});
|
||||
};
|
73
resources/scripts/api/account/webauthn/registerKey.ts
Normal file
73
resources/scripts/api/account/webauthn/registerKey.ts
Normal 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);
|
||||
});
|
||||
};
|
|
@ -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<Login
|
|||
password,
|
||||
'g-recaptcha-response': recaptchaData,
|
||||
})
|
||||
.then(response => {
|
||||
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);
|
||||
|
|
|
@ -9,8 +9,8 @@ export default (token: string, code: string, recoveryToken?: string): Promise<Lo
|
|||
recovery_token: (recoveryToken && recoveryToken.length > 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);
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -5,11 +5,7 @@ import { breakpoint } from '@/theme';
|
|||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import tw from 'twin.macro';
|
||||
|
||||
type Props = React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, 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<HTMLFormElement, Props>(({ title, ...props }, ref) => (
|
||||
<Container>
|
||||
const Inner = ({ children }: { children: React.ReactNode }) => (
|
||||
<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 &&
|
||||
<h2 css={tw`text-3xl text-center text-neutral-100 font-medium py-4`}>
|
||||
{title}
|
||||
</h2>
|
||||
}
|
||||
<FlashMessageRender css={tw`mb-2 px-1`}/>
|
||||
<Form {...props} ref={ref}>
|
||||
<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>
|
||||
{children}
|
||||
<p css={tw`text-center text-neutral-500 text-xs mt-4`}>
|
||||
© 2015 - {(new Date()).getFullYear()}
|
||||
<a
|
||||
|
@ -57,5 +55,31 @@ export default forwardRef<HTMLFormElement, Props>(({ title, ...props }, ref) =>
|
|||
Pterodactyl Software
|
||||
</a>
|
||||
</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>
|
||||
));
|
||||
|
||||
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;
|
||||
|
|
|
@ -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;
|
|
@ -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) => (
|
|||
<Switch location={location}>
|
||||
<Route path={`${match.path}/login`} component={LoginContainer} exact/>
|
||||
<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/reset/:token`} component={ResetPasswordContainer}/>
|
||||
<Route path={`${match.path}/checkpoint`}/>
|
||||
|
|
Loading…
Reference in a new issue