Skip to content

Commit d5c971d

Browse files
author
Dave MacFarlane
committed
Prompt for MFA code
1 parent 5fbf3ba commit d5c971d

File tree

8 files changed

+278
-11
lines changed

8 files changed

+278
-11
lines changed

modules/login/jsx/mfaPrompt.tsx

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import {createRoot} from 'react-dom/client';
2+
import {useState, useEffect, useCallback} from 'react';
3+
4+
function Digit(props: {value: number|null|string, onChange: (newvalue: number) => boolean}) {
5+
return <input style={{flex: 1,
6+
width: "1em",
7+
fontSize: "3em",
8+
marginLeft: "0.5ex",
9+
textAlign: "center"
10+
}}
11+
type="text"
12+
readOnly={true}
13+
onKeyDown={(e: React.KeyboardEvent) => {
14+
e.preventDefault();
15+
if(e.keyCode >= 48 /* '0' */ && e.keyCode <= 57 /* '9' */) {
16+
if(props.onChange(e.keyCode-48)) {
17+
const target = e.target as HTMLElement;
18+
(target.nextSibling as HTMLElement)?.focus();
19+
}
20+
return;
21+
}
22+
if(e.key == "ArrowLeft") {
23+
const target = e.target as HTMLElement;
24+
(target.previousSibling as HTMLElement)?.focus();
25+
} if(e.key == "ArrowRight") {
26+
const target = e.target as HTMLElement;
27+
(target.nextSibling as HTMLElement)?.focus();
28+
}
29+
}
30+
}
31+
value={props.value || ""}
32+
/>;
33+
}
34+
function CodePrompt(props: {onValidate: () => void}) {
35+
const [code, setCode] = useState<[number|null, number|null, number|null, number|null, number|null, number|null]>([null, null, null, null, null, null]);
36+
const digitCallback = useCallback( (index: number, value: number): boolean => {
37+
if(value >= 0 && value <= 9) {
38+
code[index] = value;
39+
setCode([...code]);
40+
return true;
41+
}
42+
return false;
43+
}, []);
44+
useEffect( () => {
45+
if(code.indexOf(null) >= 0) {
46+
console.log("not yet entered");
47+
return;
48+
}
49+
fetch("/login/mfa",
50+
{
51+
method: "POST",
52+
body: JSON.stringify({"code": code.join("")}),
53+
credentials: 'same-origin',
54+
55+
}
56+
).then( (resp) => {
57+
if(!resp.ok) {
58+
console.warn("invalid response");
59+
}
60+
return resp.json();
61+
}).then( (json) => {
62+
if(json["success"]) {
63+
window.location.reload();
64+
}
65+
console.log(json);
66+
}).catch( (e) => {
67+
console.error("error validating code");
68+
});
69+
70+
console.log("validate code")
71+
//call onValidate
72+
}, [code]);
73+
74+
75+
76+
return <div style={{display: "flex"}}>
77+
<Digit value={code[0] === 0 ? "0" : code[0]} onChange={(newval: number) => digitCallback(0, newval)}/>
78+
<Digit value={code[1] === 0 ? "0" : code[1]} onChange={(newval: number) => digitCallback(1, newval)}/>
79+
<Digit value={code[2] === 0 ? "0" : code[2]} onChange={(newval: number) => digitCallback(2, newval)}/>
80+
<Digit value={code[3] === 0 ? "0" : code[3]} onChange={(newval: number) => digitCallback(3, newval)}/>
81+
<Digit value={code[4] === 0 ? "0" : code[4]} onChange={(newval: number) => digitCallback(4, newval)}/>
82+
<Digit value={code[5] === 0 ? "0" : code[5]} onChange={(newval: number) => digitCallback(5, newval)}/>
83+
</div>;
84+
}
85+
86+
function MFAPrompt() {
87+
return (<div>
88+
<h2>Multifactor authentication required</h2>
89+
<p>Enter the code from your authenticator app below to proceed.</p>
90+
<CodePrompt onValidate={() => {window.location.reload()}}/>
91+
92+
</div>)
93+
94+
}
95+
96+
declare const loris: any;
97+
window.addEventListener('load', () => {
98+
createRoot(
99+
document.getElementsByClassName('main-content')[0]
100+
).render(
101+
<MFAPrompt />
102+
);
103+
});

modules/login/php/mfa.class.inc

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace LORIS\login;
4+
5+
use \Psr\Http\Message\ServerRequestInterface;
6+
use \Psr\Http\Message\ResponseInterface;
7+
use \LORIS\Middleware\ETagCalculator;
8+
9+
/**
10+
* POST request for authentication.
11+
*
12+
* Used to request account.
13+
*
14+
* @category Loris
15+
* @package Login
16+
* @author Alizée Wickenheiser <[email protected]>
17+
* @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
18+
* @link https://www.github.com/aces/Loris/
19+
*/
20+
class MFA extends \NDB_Page
21+
{
22+
public $skipTemplate = true;
23+
24+
/**
25+
* Include additional JS files
26+
*
27+
* @return array of javascript to be inserted
28+
*/
29+
function getJSDependencies()
30+
{
31+
$factory = \NDB_Factory::singleton();
32+
$baseURL = $factory->settings()->getBaseURL();
33+
$deps = parent::getJSDependencies();
34+
return array_merge(
35+
$deps,
36+
[
37+
$baseURL . '/login/js/mfaPrompt.js',
38+
]
39+
);
40+
}
41+
/**
42+
* Include additional CSS files:
43+
*
44+
* @return array of javascript to be inserted
45+
*/
46+
function getCSSDependencies()
47+
{
48+
$factory = \NDB_Factory::singleton();
49+
$baseURL = $factory->settings()->getBaseURL();
50+
$deps = parent::getCSSDependencies();
51+
return array_merge(
52+
$deps,
53+
[$baseURL . '/login/css/login.css']
54+
);
55+
}
56+
/**
57+
* This function will return a json object for login module.
58+
*
59+
* @param ServerRequestInterface $request The incoming PSR7 request
60+
*
61+
* @return ResponseInterface The outgoing PSR7 response
62+
*/
63+
public function handle(ServerRequestInterface $request) : ResponseInterface
64+
{
65+
// Ensure POST request.
66+
switch ($request->getMethod()) {
67+
case 'GET':
68+
return parent::handle($request);
69+
case 'POST':
70+
return $this->_handlePOST($request);
71+
default:
72+
return new \LORIS\Http\Response\JSON\MethodNotAllowed(
73+
$this->allowedMethods()
74+
);
75+
}
76+
}
77+
78+
/**
79+
* Processes the values & saves to database and return a json response.
80+
*
81+
* @param ServerRequestInterface $request The incoming PSR7 request.
82+
*
83+
* @return ResponseInterface The outgoing PSR7 response
84+
*/
85+
private function _handlePOST(ServerRequestInterface $request) : ResponseInterface
86+
{
87+
$requestdata = json_decode((string )$request->getBody(), true);
88+
$user = $request->getAttribute("user");
89+
if(!isset($requestdata['code'])) {
90+
return new \LORIS\Http\Response\JSON\Unauthorized("missing code");
91+
}
92+
93+
$validator = $user->getTOTPValidator();
94+
$counter = $validator->getTimeCounter();
95+
$wantCode = $validator->getCode($counter, 6);
96+
if($wantCode === $requestdata['code']) {
97+
$login = $_SESSION['State']->getProperty('login');
98+
$login->setPassedMFA();
99+
return new \LORIS\Http\Response\JSON\OK(["success" => "validated code"]);
100+
} else {
101+
return new \LORIS\Http\Response\JSON\Unauthorized("invalid code");
102+
}
103+
}
104+
105+
/**
106+
* Return an array of valid HTTP methods for this endpoint
107+
*
108+
* @return string[] Valid versions
109+
*/
110+
protected function allowedMethods(): array
111+
{
112+
return ['GET', 'POST'];
113+
}
114+
115+
/**
116+
* Returns true if the user has permission to access
117+
* the Login module
118+
*
119+
* @param \User $user The user whose access is being checked
120+
*
121+
* @return bool true if user has permission
122+
*/
123+
function _hasAccess(\User $user) : bool
124+
{
125+
return true;
126+
}
127+
}

modules/my_preferences/php/mfa.class.inc

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,12 @@ class MFA extends \NDB_Page
7676
return new \LORIS\Http\Response\JSON\BadRequest('Code does not match expected value');
7777
}
7878
$db = $this->loris->getDatabaseConnection();
79-
$db->update("users", ['TOTPSecret' => $secret], ['ID' => $user->getId()]);
79+
$db->_trackChanges = false;
80+
// We are dealing with binary data that never gets exposed to the user
81+
$db->unsafeUpdate("users", ['TOTPSecret' => $secret], ['ID' => $user->getId()]);
82+
83+
$login = $_SESSION['state']->getProperty('login');
84+
$login->setPassedMFA();
8085
return new \LORIS\Http\Response\JSON\OK(['ok' => 'success',
8186
'message' => 'Successfully registered multifactor authenticator']);
8287
}

php/libraries/AnonymousUser.class.inc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,8 @@ class AnonymousUser extends \User
8585
{
8686
return [];
8787
}
88+
89+
function totpRequired() : bool {
90+
return false;
91+
}
8892
}

php/libraries/SinglePointLogin.class.inc

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -601,7 +601,15 @@ class SinglePointLogin
601601
$_SESSION['State']->setProperty('login', $this);
602602
}
603603

604-
function passedMFA() : bool {
604+
public function passedMFA() {
605605
return $_SESSION['PassedMFA'] ?? false === true;
606606
}
607+
public function setPassedMFA() {
608+
// NDB_Client called session_write_close, we need to re-start it
609+
// to edit a session variable.
610+
session_start();
611+
$_SESSION['PassedMFA'] = true;
612+
session_write_close();
613+
}
614+
607615
}

php/libraries/User.class.inc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,14 @@ class User extends UserPermissions implements
694694
return $this->userInfo['Pending_approval'] == 'Y';
695695
}
696696

697+
698+
public function getTOTPValidator() : ?\LORIS\Security\OTP\TOTP
699+
{
700+
if($this->userInfo['TOTPSecret'] === null) {
701+
return null;
702+
}
703+
return new \LORIS\Security\OTP\TOTP(secret: $this->userInfo['TOTPSecret']);
704+
}
697705
public function totpRequired(): bool
698706
{
699707
return $this->userInfo['TOTPSecret'] !== null;

src/Middleware/MFA.php

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,19 +42,31 @@ public function process(
4242
) : ResponseInterface {
4343
$loris = $request->getAttribute("loris");
4444
$user = $request->getAttribute("user");
45-
if($user instanceof \LORIS\AnonymousUser) {
46-
// No MFA required on the login page
47-
return $this->next->process($request, $handler);
48-
}
49-
// Should only be true for the MFA validation endpoints
50-
if($request->getAttribute("bypassMFA") === true) {
45+
if($user->totpRequired() === false) {
5146
return $this->next->process($request, $handler);
5247
}
5348
$singlepointlogin = $_SESSION['State']->getProperty('login');
54-
if($singlepointlogin->passedMFA()) {
49+
if($singlepointlogin->passedMFA() === true) {
5550
return $this->next->process($request, $handler);
51+
}
5652

53+
$loginmodule = $loris->getModule("login");
54+
$loginmodule->registerAutoloader();
55+
$page = $loginmodule->loadPage($loris, "mfa");
56+
$baseURL = $request->getAttribute("baseurl");
57+
58+
// Whitelist of resources needed to load the MFA prompt page
59+
if(str_ends_with($request->getURI()->getPath(), ".js") ||
60+
str_ends_with($request->getURI()->getPath(), ".css") ||
61+
str_ends_with($request->getURI()->getPath(), "Authentication") ||
62+
str_ends_with($request->getURI()->getPath(), "summary_statistics")) {
63+
return $this->next->process($request, $handler);
5764
}
58-
return new \LORIS\Http\Response\JSON\OK(["foo" => 'xx']);
65+
return new AnonymousPageDecorationMiddleware(
66+
$baseURL ?? "",
67+
\NDB_Config::singleton(),
68+
$page->getJSDependencies(),
69+
$page->getCSSDependencies(),
70+
)->process($request, $page);
5971
}
6072
}

webpack.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const target = process.env.target;
1515
const lorisModules: Record<string, string[]> = {
1616
media: ['CandidateMediaWidget', 'mediaIndex'],
1717
issue_tracker: ['issueTrackerIndex', 'index', 'CandidateIssuesWidget'],
18-
login: ['loginIndex'],
18+
login: ['loginIndex', 'mfaPrompt'],
1919
publication: ['publicationIndex', 'viewProjectIndex'],
2020
document_repository: ['docIndex', 'editFormIndex'],
2121
candidate_parameters: ['CandidateParameters', 'ConsentWidget', 'DiagnosisEvolution'],

0 commit comments

Comments
 (0)