316 lines
11 KiB
TypeScript
316 lines
11 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) |