435 lines
12 KiB
TypeScript
435 lines
12 KiB
TypeScript
import { h, Component, render } from "preact";
|
|
import "inputs";
|
|
import "./u2f-api-polyfill";
|
|
|
|
import sha from "sha512";
|
|
import { setCookie, getCookie } from "cookie";
|
|
|
|
let appname = "test";
|
|
|
|
function Loader() {
|
|
return (
|
|
<div class="loader_box" id="loader">
|
|
<div class="loader"></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
class Username extends Component<
|
|
{ username: string; onNext: (username: string, salt: string) => void },
|
|
{ error: string; loading: boolean }
|
|
> {
|
|
username_input: HTMLInputElement;
|
|
constructor() {
|
|
super();
|
|
this.state = { error: undefined, loading: false };
|
|
}
|
|
|
|
async onClick() {
|
|
this.setState({ loading: true });
|
|
try {
|
|
let res = await fetch(
|
|
"/api/user/login?type=username&username=" +
|
|
this.username_input.value,
|
|
{
|
|
method: "POST",
|
|
}
|
|
)
|
|
.then((e) => {
|
|
if (e.status !== 200) throw new Error(e.statusText);
|
|
return e.json();
|
|
})
|
|
.then((data) => {
|
|
if (data.error) {
|
|
return Promise.reject(new Error(data.error));
|
|
}
|
|
return data;
|
|
});
|
|
let salt = res.salt;
|
|
this.props.onNext(this.username_input.value, salt);
|
|
} catch (err) {
|
|
this.setState({
|
|
error: err.message,
|
|
});
|
|
}
|
|
this.setState({ loading: false });
|
|
}
|
|
|
|
render() {
|
|
if (this.state.loading) return <Loader />;
|
|
return (
|
|
<div>
|
|
<div class="floating group">
|
|
<input
|
|
onKeyDown={(e) => {
|
|
let k = e.keyCode | e.which;
|
|
if (k === 13) this.onClick();
|
|
this.setState({ error: undefined });
|
|
}}
|
|
type="text"
|
|
value={
|
|
this.username_input
|
|
? this.username_input.value
|
|
: this.props.username
|
|
}
|
|
autofocus
|
|
ref={(elm) => (elm ? (this.username_input = elm) : undefined)}
|
|
/>
|
|
<span class="highlight"></span>
|
|
<span class="bar"></span>
|
|
<label>Username or Email</label>
|
|
{this.state.error ? (
|
|
<div class="error"> {this.state.error}</div>
|
|
) : undefined}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="mdc-button mdc-button--raised spanned-btn"
|
|
onClick={() => this.onClick()}
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
enum TFATypes {
|
|
OTC,
|
|
BACKUP_CODE,
|
|
YUBI_KEY,
|
|
APP_ALLOW,
|
|
}
|
|
|
|
interface TwoFactors {
|
|
id: string;
|
|
name: string;
|
|
type: TFATypes;
|
|
}
|
|
|
|
class Password extends Component<
|
|
{
|
|
username: string;
|
|
salt: string;
|
|
onNext: (login: Token, special: Token, tfa: TwoFactors[]) => void;
|
|
},
|
|
{ error: string; loading: boolean }
|
|
> {
|
|
password_input: HTMLInputElement;
|
|
constructor() {
|
|
super();
|
|
this.state = { error: undefined, loading: false };
|
|
}
|
|
|
|
async onClick() {
|
|
this.setState({
|
|
loading: true,
|
|
});
|
|
|
|
try {
|
|
let pw = sha(this.props.salt + this.password_input.value);
|
|
let { login, special, tfa } = await fetch(
|
|
"/api/user/login?type=password",
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
username: this.props.username,
|
|
password: pw,
|
|
}),
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
}
|
|
)
|
|
.then((e) => {
|
|
if (e.status !== 200) throw new Error(e.statusText);
|
|
return e.json();
|
|
})
|
|
.then((data) => {
|
|
if (data.error) {
|
|
return Promise.reject(new Error(data.error));
|
|
}
|
|
return data;
|
|
});
|
|
this.props.onNext(login, special, tfa);
|
|
} catch (err) {
|
|
this.setState({ error: err.messagae });
|
|
}
|
|
this.setState({ loading: false });
|
|
}
|
|
|
|
render() {
|
|
if (this.state.loading) return <Loader />;
|
|
return (
|
|
<div>
|
|
<div class="floating group">
|
|
<input
|
|
onKeyDown={(e) => {
|
|
let k = e.keyCode | e.which;
|
|
if (k === 13) this.onClick();
|
|
this.setState({ error: undefined });
|
|
}}
|
|
type="password"
|
|
ref={(elm: HTMLInputElement) => {
|
|
if (elm) {
|
|
this.password_input = elm;
|
|
setTimeout(() => elm.focus(), 200);
|
|
// elm.focus();
|
|
}
|
|
}}
|
|
/>
|
|
<span class="highlight"></span>
|
|
<span class="bar"></span>
|
|
<label>Password</label>
|
|
{this.state.error ? (
|
|
<div class="error"> {this.state.error}</div>
|
|
) : undefined}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="mdc-button mdc-button--raised spanned-btn"
|
|
onClick={() => this.onClick()}
|
|
>
|
|
Login
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
class TwoFactor extends Component<
|
|
{ twofactors: TwoFactors[]; next: (id: string, type: TFATypes) => void },
|
|
{}
|
|
> {
|
|
render() {
|
|
let tfs = this.props.twofactors.map((fac) => {
|
|
let name: string;
|
|
switch (fac.type) {
|
|
case TFATypes.OTC:
|
|
name = "Authenticator";
|
|
break;
|
|
case TFATypes.BACKUP_CODE:
|
|
name = "Backup code";
|
|
break;
|
|
case TFATypes.APP_ALLOW:
|
|
name = "Use App: %s";
|
|
break;
|
|
case TFATypes.YUBI_KEY:
|
|
name = "Use Yubikey: %s";
|
|
break;
|
|
}
|
|
|
|
name = name.replace("%s", fac.name ? fac.name : "");
|
|
|
|
return (
|
|
<li
|
|
onClick={() => {
|
|
console.log("Click on Solution");
|
|
this.props.next(fac.id, fac.type);
|
|
}}
|
|
>
|
|
{name}
|
|
</li>
|
|
);
|
|
});
|
|
return (
|
|
<div>
|
|
<h1>Select one</h1>
|
|
<ul>{tfs}</ul>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
// class TFA_YubiKey extends Component<{ id: string, login: Token, special: Token, next: (login: Token, special: Token) => void }, {}> {
|
|
// render() {
|
|
|
|
// }
|
|
// }
|
|
|
|
enum Page {
|
|
username,
|
|
password,
|
|
twofactor,
|
|
yubikey,
|
|
}
|
|
|
|
interface Token {
|
|
token: string;
|
|
expires: string;
|
|
}
|
|
|
|
async function apiRequest(
|
|
endpoint: string,
|
|
method: "GET" | "POST" | "DELETE" | "PUT" = "GET",
|
|
body: string = undefined
|
|
) {
|
|
return fetch(endpoint, {
|
|
method,
|
|
body,
|
|
credentials: "same-origin",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
})
|
|
.then((e) => {
|
|
if (e.status !== 200) throw new Error(e.statusText);
|
|
return e.json();
|
|
})
|
|
.then((data) => {
|
|
if (data.error) {
|
|
return Promise.reject(new Error(data.error));
|
|
}
|
|
return data;
|
|
});
|
|
}
|
|
|
|
class App extends Component<
|
|
{},
|
|
{
|
|
page: Page;
|
|
username: string;
|
|
salt: string;
|
|
twofactor: TwoFactors[];
|
|
twofactor_id: string;
|
|
}
|
|
> {
|
|
login: Token;
|
|
special: Token;
|
|
constructor() {
|
|
super();
|
|
this.state = {
|
|
page: Page.username,
|
|
username: getCookie("username"),
|
|
salt: undefined,
|
|
twofactor: [],
|
|
twofactor_id: null,
|
|
};
|
|
}
|
|
|
|
setCookies() {
|
|
setCookie(
|
|
"login",
|
|
this.login.token,
|
|
new Date(this.login.expires).toUTCString()
|
|
);
|
|
setCookie(
|
|
"special",
|
|
this.special.token,
|
|
new Date(this.special.expires).toUTCString()
|
|
);
|
|
}
|
|
|
|
finish() {
|
|
this.setCookies();
|
|
let d = new Date();
|
|
d.setTime(d.getTime() + 30 * 24 * 60 * 60 * 1000); //Keep the username 30 days
|
|
setCookie("username", this.state.username, d.toUTCString());
|
|
let url = new URL(window.location.href);
|
|
let state = url.searchParams.get("state");
|
|
let red = "/";
|
|
|
|
if (state) {
|
|
let base64 = url.searchParams.get("base64");
|
|
if (base64) red = atob(state);
|
|
else red = state;
|
|
}
|
|
window.location.href = red;
|
|
}
|
|
|
|
render() {
|
|
let cont;
|
|
switch (this.state.page) {
|
|
case Page.username:
|
|
cont = (
|
|
<Username
|
|
username={this.state.username}
|
|
onNext={(username, salt) => {
|
|
this.setState({ username, salt, page: Page.password });
|
|
localStorage.setItem("username", username);
|
|
}}
|
|
/>
|
|
);
|
|
break;
|
|
case Page.password:
|
|
cont = (
|
|
<Password
|
|
username={this.state.username}
|
|
salt={this.state.salt}
|
|
onNext={(login, special, twofactor) => {
|
|
this.login = login;
|
|
this.special = special;
|
|
this.setCookies();
|
|
|
|
if (!twofactor) {
|
|
this.finish();
|
|
} else {
|
|
this.setState({ twofactor, page: Page.twofactor });
|
|
}
|
|
}}
|
|
/>
|
|
);
|
|
break;
|
|
case Page.twofactor:
|
|
cont = (
|
|
<TwoFactor
|
|
twofactors={this.state.twofactor}
|
|
next={async (id, type) => {
|
|
if (type === TFATypes.YUBI_KEY) {
|
|
let { request } = await apiRequest(
|
|
"/api/user/twofactor/yubikey",
|
|
"GET"
|
|
);
|
|
console.log(request);
|
|
(window as any).u2f.sign(
|
|
request.appId,
|
|
[request.challenge],
|
|
[request],
|
|
async (response) => {
|
|
let res = await apiRequest(
|
|
"/api/user/twofactor/yubikey",
|
|
"PUT",
|
|
JSON.stringify({ response })
|
|
);
|
|
if (res.success) {
|
|
this.login.expires = res.login_exp;
|
|
this.special.expires = res.special_exp;
|
|
this.finish();
|
|
}
|
|
}
|
|
);
|
|
}
|
|
}}
|
|
/>
|
|
);
|
|
break;
|
|
// case Page.yubikey:
|
|
// cont = <TFA_YubiKey id={this.state.twofactor_id} login={this.login} special={this.special} next={(login, special) => {
|
|
// this.login = login;
|
|
// this.special = special;
|
|
// this.finish()
|
|
// }} />
|
|
// break;
|
|
}
|
|
return (
|
|
<div>
|
|
<header>
|
|
<h1>Login</h1>
|
|
</header>
|
|
<form action="JavaScript:void(0)">{cont}</form>
|
|
<footer>
|
|
<p>Powered by {appname}</p>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
document.addEventListener(
|
|
"DOMContentLoaded",
|
|
function () {
|
|
render(<App />, document.body.querySelector("#content"));
|
|
},
|
|
false
|
|
);
|