Skip to content

Commit

Permalink
Merge pull request #98 from sebadob/impl-increasing-failed-login-delay
Browse files Browse the repository at this point in the history
Impl increasing failed login delay
  • Loading branch information
sebadob authored Oct 25, 2023
2 parents 7f7a675 + 3e0a389 commit 5d19d2d
Show file tree
Hide file tree
Showing 14 changed files with 548 additions and 122 deletions.
51 changes: 41 additions & 10 deletions frontend/src/routes/oidc/authorize/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import {onMount, tick} from "svelte";
import {authorize, authorizeRefresh, getClientLogo, postPasswordResetRequest} from "../../../utils/dataFetching.js";
import * as yup from 'yup';
import {extractFormErrors, getQueryParams, saveCsrfToken} from "../../../utils/helpers.js";
import {extractFormErrors, formatDateFromTs, getQueryParams, saveCsrfToken} from "../../../utils/helpers.js";
import Button from "$lib/Button.svelte";
import WebauthnRequest from "../../../components/webauthn/WebauthnRequest.svelte";
import {scale} from 'svelte/transition';
Expand Down Expand Up @@ -44,6 +44,7 @@
let showReset = false;
let showResetRequest = false;
let emailSuccess = false;
let tooManyRequests = false;
let emailAfterSubmit = '';
let formValues = {email: '', password: ''};
Expand Down Expand Up @@ -134,6 +135,8 @@
}
async function onSubmit() {
err = '';
try {
await schema.validate(formValues, {abortEarly: false});
formErrors = {};
Expand Down Expand Up @@ -172,6 +175,23 @@
} else if (res.status === 200) {
err = '';
webauthnData = await res.json();
} else if (res.status === 429) {
let notBefore = Number.parseInt(res.headers.get('x-retry-not-before'));
let nbfDate = formatDateFromTs(notBefore);
let diff = notBefore * 1000 - new Date().getTime();
console.log(diff);
tooManyRequests = true;
err = `${t.http429} ${nbfDate}`;
formValues.email = '';
formValues.password = '';
needsPassword = false;
setTimeout(() => {
tooManyRequests = false;
err = '';
}, diff);
} else if (!needsPassword) {
// this will happen always if the user does the first try with a password-only account
// the good thing about this is, that it is a prevention against autofill passwords from the browser
Expand All @@ -189,6 +209,8 @@
// a password and afterward changes his email again
if (needsPassword && emailAfterSubmit !== formValues.email) {
needsPassword = false;
formValues.password = '';
err = '';
}
}
Expand Down Expand Up @@ -270,6 +292,7 @@
bind:error={formErrors.email}
autocomplete="email"
placeholder={t.email}
disabled={tooManyRequests}
on:enter={onSubmit}
on:input={onEmailInput}
>
Expand All @@ -284,12 +307,13 @@
bind:error={formErrors.password}
autocomplete="current-password"
placeholder={t.password}
disabled={tooManyRequests}
on:enter={onSubmit}
>
{t.password?.toUpperCase()}
</PasswordInput>
{#if showResetRequest}
{#if showResetRequest && !tooManyRequests}
<div
role="button"
tabindex="0"
Expand All @@ -303,14 +327,20 @@
{/if}
{/if}
{#if showReset}
<div class="btn">
<Button on:click={requestReset}>{t.passwordRequest?.toUpperCase()}</Button>
</div>
{:else}
<div class="btn">
<Button on:click={onSubmit} bind:isLoading>{t.login?.toUpperCase()}</Button>
</div>
{#if !tooManyRequests}
{#if showReset}
<div class="btn">
<Button on:click={requestReset}>
{t.passwordRequest?.toUpperCase()}
</Button>
</div>
{:else}
<div class="btn">
<Button on:click={onSubmit} bind:isLoading>
{t.login?.toUpperCase()}
</Button>
</div>
{/if}
{/if}
{#if err}
Expand Down Expand Up @@ -346,6 +376,7 @@
}
.errMsg {
max-width: 15rem;
margin: -5px 10px 0 10px;
color: var(--col-err)
}
Expand Down
34 changes: 0 additions & 34 deletions frontend/src/utils/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,6 @@ import {
import sjcl from "sjcl";
import { decode, encode } from "base64-arraybuffer";

// export function getCookie(cname) {
// let name = cname + "=";
// let decodedCookie = decodeURIComponent(document.cookie);
// let ca = decodedCookie.split(';');
// for (let i = 0; i < ca.length; i++) {
// let c = ca[i];
// while (c.charAt(0) === ' ') {
// c = c.substring(1);
// }
// if (c.indexOf(name) === 0) {
// return c.substring(name.length, c.length);
// }
// }
// return "";
// }
//
// function deleteCookie(name) {
// document.cookie = name + '=; Max-Age=-1;';
// }

export function extractFormErrors(err) {
return err.inner.reduce((acc, err) => {
return {...acc, [err.path]: err.message};
Expand Down Expand Up @@ -72,18 +52,9 @@ export const saveIdToken = (token) => {
localStorage.setItem(ID_TOKEN, token);
}

export const getIdToken = () => {
return localStorage.getItem(ID_TOKEN) || '';
}

export const saveAccessToken = (token) => {
localStorage.setItem(ACCESS_TOKEN, token);
}

export const getAccessToken = () => {
return localStorage.getItem(ACCESS_TOKEN) || '';
}

export const getVerifierFromStorage = () => {
return localStorage.getItem(PKCE_VERIFIER) || '';
};
Expand Down Expand Up @@ -134,11 +105,6 @@ export const computePow = (powChallenge) => {
}


// export const dateFromUtcTs = (ts) => {
// const utcOffsetMinutes = -new Date().getTimezoneOffset();
// return new Date((ts + utcOffsetMinutes * 60) * 1000);
// }

export const formatDateToDateInput = date => {
return date.toISOString().split('.')[0];
}
Expand Down
2 changes: 2 additions & 0 deletions rauthy-common/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ use std::str::FromStr;
pub const RAUTHY_VERSION: &str = env!("CARGO_PKG_VERSION");

pub const HEADER_HTML: (&str, &str) = ("content-type", "text/html;charset=utf-8");
pub const HEADER_RETRY_NOT_BEFORE: &str = "x-retry-not-before";
pub const APPLICATION_JSON: &str = "application/json";

pub const TOKEN_API_KEY: &str = "API-Key";
pub const TOKEN_BEARER: &str = "Bearer";
pub const COOKIE_SESSION: &str = "rauthy-session";
Expand Down
20 changes: 16 additions & 4 deletions rauthy-common/src/error_response.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::constants::{APPLICATION_JSON, HEADER_HTML};
use crate::constants::{APPLICATION_JSON, HEADER_HTML, HEADER_RETRY_NOT_BEFORE};
use crate::utils::build_csp_header;
use actix_multipart::MultipartError;
use actix_web::error::BlockingError;
Expand Down Expand Up @@ -30,6 +30,7 @@ pub enum ErrorResponseType {
PasswordRefresh,
SessionExpired,
SessionTimeout,
TooManyRequests(i64),
Unauthorized,
}

Expand Down Expand Up @@ -75,14 +76,25 @@ impl ResponseError for ErrorResponse {
| ErrorResponseType::SessionExpired
| ErrorResponseType::SessionTimeout
| ErrorResponseType::Unauthorized => StatusCode::UNAUTHORIZED,
ErrorResponseType::TooManyRequests(_not_before_timestamp) => {
StatusCode::TOO_MANY_REQUESTS
}
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}

fn error_response(&self) -> HttpResponse {
HttpResponseBuilder::new(self.status_code())
.content_type(APPLICATION_JSON)
.body(serde_json::to_string(&self).unwrap())
match self.error {
ErrorResponseType::TooManyRequests(not_before_timestamp) => {
HttpResponseBuilder::new(self.status_code())
.insert_header((HEADER_RETRY_NOT_BEFORE, not_before_timestamp))
.insert_header(HEADER_HTML)
.body(serde_json::to_string(&self.message).unwrap())
}
_ => HttpResponseBuilder::new(self.status_code())
.content_type(APPLICATION_JSON)
.body(serde_json::to_string(&self).unwrap()),
}
}
}

Expand Down
110 changes: 110 additions & 0 deletions rauthy-common/src/ip_blacklist_handler.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
use chrono::{DateTime, Utc};
use std::collections::HashMap;
use tokio::sync::oneshot;
use tracing::error;

#[derive(Debug, PartialEq)]
pub enum IpBlacklistReq {
Blacklist(IpBlacklist),
BlacklistCheck(IpBlacklistCheck),
BlacklistCleanup(IpBlacklistCleanup),
LoginCheck(IpFailedLoginCheck),
LoginCleanup(IpBlacklistCleanup),
}

#[derive(Debug, PartialEq)]
pub struct IpBlacklist {
pub ip: String,
pub exp: DateTime<Utc>,
}

#[derive(Debug)]
pub struct IpBlacklistCheck {
pub ip: String,
pub tx: oneshot::Sender<Option<DateTime<Utc>>>,
}

impl PartialEq for IpBlacklistCheck {
fn eq(&self, other: &Self) -> bool {
self.ip == other.ip
}
}

#[derive(Debug)]
pub struct IpFailedLoginCheck {
pub ip: String,
pub increase_counter: bool,
/// counter for invalid requests from this IP
pub tx: oneshot::Sender<Option<u32>>,
}

impl PartialEq for IpFailedLoginCheck {
fn eq(&self, other: &Self) -> bool {
self.ip == other.ip && self.increase_counter == other.increase_counter
}
}

#[derive(Debug, PartialEq)]
pub struct IpBlacklistCleanup {
pub ip: String,
}

/// Handles blacklisted IP's and IP's with failed logins
pub async fn run(rx: flume::Receiver<IpBlacklistReq>) {
let mut data_blacklist: HashMap<String, DateTime<Utc>> = HashMap::with_capacity(2);
let mut data_failed_logins: HashMap<String, u32> = HashMap::with_capacity(5);

loop {
match rx.recv_async().await {
Ok(req) => match req {
IpBlacklistReq::Blacklist(req) => {
data_blacklist.insert(req.ip, req.exp);
}

IpBlacklistReq::BlacklistCheck(check) => {
check
.tx
.send(data_blacklist.get(&check.ip).cloned())
.expect("oneshot receiver to not be closed");
}

IpBlacklistReq::LoginCheck(check) => {
let counter = match data_failed_logins.get_mut(&check.ip) {
None => {
if check.increase_counter {
data_failed_logins.insert(check.ip, 1);
Some(1)
} else {
None
}
}
Some(counter) => {
*counter += 1;
Some(*counter)
}
};

check
.tx
.send(counter)
.expect("oneshot receiver to not be closed");
}

IpBlacklistReq::BlacklistCleanup(req) => {
data_blacklist.remove(&req.ip);
}

IpBlacklistReq::LoginCleanup(req) => {
data_failed_logins.remove(&req.ip);
}
},

Err(err) => {
error!(
"ip_blacklist_handler: {:?}\n\nThis should never happen!",
err
);
}
}
}
}
1 change: 1 addition & 0 deletions rauthy-common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use std::str::FromStr;

pub mod constants;
pub mod error_response;
pub mod ip_blacklist_handler;
pub mod password_hasher;
pub mod utils;

Expand Down
11 changes: 7 additions & 4 deletions rauthy-handlers/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ pub fn map_auth_step(
data: &web::Data<AppState>,
auth_step: AuthStep,
req: &HttpRequest,
// Ok => HttpResponse + has_password_been_hashed
) -> Result<(HttpResponse, bool), ErrorResponse> {
// the bool for Ok() is true is the password has been hashed
// the bool for Err() means if we need to add a login delay (and none otherwise for better UX)
) -> Result<(HttpResponse, bool), (ErrorResponse, bool)> {
match auth_step {
AuthStep::LoggedIn(res) => {
let mut resp = HttpResponse::Accepted()
Expand Down Expand Up @@ -78,10 +79,12 @@ pub fn map_auth_step(
WebauthnCookie::parse_validate(&req.cookie(COOKIE_MFA), &data.enc_keys)
{
if mfa_cookie.email != res.email {
add_req_mfa_cookie(data, &mut resp, res.email.clone())?;
add_req_mfa_cookie(data, &mut resp, res.email.clone())
.map_err(|err| (err, true))?;
}
} else {
add_req_mfa_cookie(data, &mut resp, res.email.clone())?;
add_req_mfa_cookie(data, &mut resp, res.email.clone())
.map_err(|err| (err, true))?;
}

Ok((resp, res.has_password_been_hashed))
Expand Down
Loading

0 comments on commit 5d19d2d

Please sign in to comment.