summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore6
-rw-r--r--Cargo.toml24
-rw-r--r--LICENSE202
-rw-r--r--README.md23
-rw-r--r--src/auth_handler/handler_info.rs30
-rw-r--r--src/auth_handler/handler_login.rs142
-rw-r--r--src/auth_handler/mod.rs170
-rw-r--r--src/auth_handler/urls.rs18
-rw-r--r--src/cookie_store.rs133
-rw-r--r--src/http_server.rs298
-rw-r--r--src/main.rs106
-rw-r--r--src/router.rs162
-rw-r--r--src/system.rs8
-rw-r--r--src/totp.rs30
-rw-r--r--test/etc/nginx.conf35
-rwxr-xr-xtest/nginx.sh18
-rwxr-xr-xtest/oathtool.sh6
-rw-r--r--test/www/index.html58
-rw-r--r--test/www/other_page.html57
19 files changed, 1526 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4e79e5d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+/target/
+/.idea/
+/rust.iml
+
+# don't care about it now
+/Cargo.lock
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..76b5cd2
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,24 @@
+[package]
+name = "nginx_auth_totp"
+version = "0.1.0"
+authors = ["Yves Fischer <yvesf+git@xapek.org>"]
+
+[dependencies]
+ascii = "0.7"
+time = "0.1"
+getopts = "0.2"
+simple_logger = "0.4"
+log = "0.3"
+oath = "0.10.*"
+evmap = "4.0.*"
+horrorshow = "0.6.*"
+random = "0.12.*"
+tokio = "0.1.*"
+tokio-threadpool = "0.1.*"
+tokio-executor = "0.1.*"
+http = "0.1.*"
+bytes = "0.4.*"
+httparse = "1.3.*"
+thread_local = "0.3.*"
+cookie = "0.11.*"
+url = "1.7.*" \ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b948b67
--- /dev/null
+++ b/README.md
@@ -0,0 +1,23 @@
+# nginx-auth-totp
+
+A very simple authentication provider to be used with nginx's `auth_request`.
+It uses TOTP (Time based One-Time Passwords) for verification. On success it stores
+a cookie which is valid for one day.
+
+### Compile
+
+It's written in rust, compile it with `cargo build`.
+
+### Run
+
+```
+Usage: nginx_auth_totp [options]
+
+Options:
+ -o, --port PORT TCP Port to listen on
+ -d, --debug Use loglevel Debug instead of Warn
+```
+
+### Nginx configuration
+
+Find example in `test/etc/nginx.conf` \ No newline at end of file
diff --git a/src/auth_handler/handler_info.rs b/src/auth_handler/handler_info.rs
new file mode 100644
index 0000000..0aeaa09
--- /dev/null
+++ b/src/auth_handler/handler_info.rs
@@ -0,0 +1,30 @@
+use std::io;
+
+use tokio::prelude::*;
+
+use http::{Request, Response, StatusCode};
+use bytes::Bytes;
+
+use ::ApplicationState;
+use super::AuthHandler;
+
+pub(in super) fn respond<'a>(auth_handler: &AuthHandler, state: &ApplicationState, req: &Request<Bytes>,
+ path_rest: &'a str) -> Response<String> {
+ let body = html! {
+ : horrorshow::helper::doctype::HTML;
+ html {
+ head {
+ title: "Hello world!";
+ }
+ body {
+ h1(id = "heading") {
+ : "Hello! This is <html />";
+ : "And path rest is: ";
+ : path_rest;
+ : "... ok :)";
+ }
+ }
+ }
+ };
+ Response::builder().body(body.to_string()).unwrap()
+} \ No newline at end of file
diff --git a/src/auth_handler/handler_login.rs b/src/auth_handler/handler_login.rs
new file mode 100644
index 0000000..83d5214
--- /dev/null
+++ b/src/auth_handler/handler_login.rs
@@ -0,0 +1,142 @@
+use std::io;
+use std::borrow::Cow;
+
+use tokio::prelude::*;
+
+use http::{Request, Response, StatusCode, Method};
+use http::header::{SET_COOKIE, COOKIE};
+use url::form_urlencoded;
+
+use ::ApplicationState;
+use ::totp;
+use super::*;
+
+
+pub(in super) fn GET<'a>(header_infos: &HeaderExtract, state: &ApplicationState, path_rest: &'a str)
+ -> Response<String> {
+ let body = if is_logged_in(&header_infos.cookies, &state.cookie_store) {
+ format!("{}", html! {
+ : horrorshow::helper::doctype::HTML;
+ html {
+ head {
+ title: "TOTP Login";
+ }
+ body {
+ h1(id = "heading") {
+ : "Currently logged in"
+ }
+ }
+ }
+ })
+ } else {
+ format!("{}", html! {
+ : horrorshow::helper::doctype::HTML;
+ html {
+ head {
+ title: "TOTP Login";
+ }
+ body {
+ h1(id = "heading") {
+ : "Login"
+ }
+ form(method="POST") {
+ label(for="token") {
+ : "Enter TOTP token"
+ }
+ input(name="token",id="token",type="text");
+ input(name="redirect", type="hidden", value=path_rest);
+ input(name="send",type="submit",value="Submit");
+ }
+ }
+ }
+ })
+ };
+ make_response(StatusCode::OK, format!("{}", body))
+}
+
+fn test_secrets(secrets: &Vec<&str>, token: &String) -> bool {
+ secrets.iter()
+ .any(|secret| {
+ match totp::verify(secret, token) {
+ Ok(true) => true,
+ Ok(false) => false,
+ Err(e) => {
+ error!("Error from totp::verify: {}", e);
+ false
+ }
+ }
+ })
+}
+
+pub(in super) fn POST<'a>(header_infos: &HeaderExtract, state: &ApplicationState, req: &Request<Bytes>)
+ -> Response<String> {
+ let mut token = None;
+ let mut redirect = None;
+ for (key, val) in form_urlencoded::parse(req.body()) {
+ if key == "token" {
+ token = Some(val.into_owned())
+ } else if key == "redirect" {
+ redirect = Some(val.into_owned())
+ }
+ }
+ if token.is_none() {
+ return error_handler_internal("missing argument 'token'".to_string());
+ }
+ let redirect = redirect.unwrap_or(Default::default());
+
+ if header_infos.totp_secrets.is_empty() {
+ return error_handler_internal("no secrets configured".to_string())
+ }
+
+ let mut ret = Response::builder();
+ let body = if test_secrets(&header_infos.totp_secrets, &token.unwrap()) {
+ let cookie_value = state.cookie_store.create_authenticated_cookie();
+ let cookie = CookieBuilder::new(COOKIE_NAME, cookie_value.to_string())
+ .http_only(true)
+ .path("/")
+ .max_age(state.cookie_max_age)
+ .finish();
+ ret.header(SET_COOKIE, cookie.to_string());
+ warn!("Authenticated user with cookie {}", cookie);
+ format!("{}", html! {
+ : horrorshow::helper::doctype::HTML;
+ html {
+ head {
+ title: "TOTP Successful";
+ meta(http-equiv="refresh", content=format!("3; URL={}", redirect))
+ }
+ body {
+ h1(id = "heading") {
+ : "Login succesful"
+ }
+ a(href="login") {
+ : "Try again... redirecting to ";
+ }
+ span {
+ : format!("{}", redirect)
+ }
+ }
+ }
+ })
+ } else {
+ format!("{}", html! {
+ : horrorshow::helper::doctype::HTML;
+ html {
+ head {
+ title: "TOTP Login failed";
+ meta(http-equiv="refresh", content="1")
+ }
+ body {
+ h1(id = "heading") {
+ : "Login failed"
+ }
+ a(href="login") {
+ : "Try again... "
+ }
+ }
+ }
+ })
+ };
+
+ ret.body(body).unwrap()
+} \ No newline at end of file
diff --git a/src/auth_handler/mod.rs b/src/auth_handler/mod.rs
new file mode 100644
index 0000000..b4e02d1
--- /dev/null
+++ b/src/auth_handler/mod.rs
@@ -0,0 +1,170 @@
+#![allow(warnings)]
+
+use std::cell::Cell;
+use std::collections::HashMap;
+use std::io;
+use std::marker::Sync;
+use std::str;
+use std::str::FromStr;
+use std::sync::{Arc, RwLock, Mutex, MutexGuard};
+use std::cell::RefCell;
+
+use time::{Tm, Duration};
+use http::{Request, Response, StatusCode, Method};
+use tokio::prelude::*;
+use horrorshow;
+use cookie::{Cookie,CookieBuilder};
+use bytes::Bytes;
+
+use router;
+use cookie_store::CookieStore;
+use cookie_store::to_cookie;
+use http_server::HttpHandler;
+
+mod urls;
+mod handler_login;
+mod handler_info;
+
+pub(in auth_handler) struct HeaderExtract<'a> {
+ totp_secrets: Vec<&'a str>,
+ cookies: Vec<Cookie<'a>>,
+}
+
+pub struct HeaderMissing {
+ name: &'static str,
+}
+
+static HTTP_HEADER_AUTHORIZATION: &'static str = r"Authorization";
+static HTTP_HEADER_X_ORIGINAL_URL: &'static str = r"X-Original-Url";
+static HTTP_HEADER_WWW_AUTHENTICATE: &'static str = r"WWW-Authenticate";
+static HTTP_HEADER_X_TOTP_SECRET: &'static str = r"X-Totp-Secret";
+static COOKIE_NAME: &'static str = r"totp_cookie";
+
+#[derive(Clone)]
+pub struct AuthHandler {
+ routing_table: router::RoutingTable<urls::Route>,
+}
+
+pub(in auth_handler) fn make_response(code: StatusCode, body: String) -> Response<String> {
+ Response::builder().status(code).body(body).unwrap()
+}
+
+pub(in auth_handler) fn error_handler_internal(body: String) -> Response<String> {
+ Response::builder().status(StatusCode::INTERNAL_SERVER_ERROR).body(body).unwrap()
+}
+
+impl HttpHandler<super::ApplicationState> for AuthHandler {
+ fn respond(&self, state: &super::ApplicationState, req: Request<Bytes>) -> Response<String> {
+ match self.routing_table.match_path(req.uri().path()) {
+ Ok((urls::Route::Info, rest)) => handler_info::respond(self, state, &req, rest),
+ Ok((urls::Route::Login, rest)) => self.login(state, &req, rest),
+ Ok((urls::Route::Logout, rest)) => self.logout(state, &req, rest),
+ Ok((urls::Route::Check, rest)) => self.check(state, &req, rest),
+ Err(error) => match error {
+ router::NoMatchingRoute =>
+ make_response(StatusCode::NOT_FOUND, "Resource not found".to_string()),
+ }
+ }
+ }
+}
+
+pub fn is_logged_in(cookies: &Vec<Cookie>, cookie_store: &CookieStore) -> bool {
+ for cookie in cookies {
+ if cookie.name() == COOKIE_NAME {
+ let cookie_value = to_cookie(cookie.value());
+ if cookie_value.is_some() && cookie_store.is_cookie_authenticated(&cookie_value.unwrap()) {
+ return true;
+ }
+ }
+ }
+ false
+}
+
+
+impl AuthHandler {
+ pub fn make() -> AuthHandler {
+ AuthHandler { routing_table: urls::create_routing_table() }
+ }
+
+ fn login<'a>(&self, state: &super::ApplicationState, req: &Request<Bytes>, path_rest: &'a str,
+ ) -> Response<String> {
+ let header_infos = match Self::parse_header_infos(req) {
+ Ok(infos) => infos,
+ Err(message) => return error_handler_internal(message),
+ };
+ match *req.method() {
+ Method::GET => handler_login::GET(&header_infos, state, path_rest),
+ Method::POST => handler_login::POST(&header_infos, state, req),
+ _ => error_handler_internal("Wrong method".to_string()),
+ }
+ }
+
+ fn logout<'a>(&self, state: &super::ApplicationState, req: &Request<Bytes>, path_rest: &'a str,
+ ) -> Response<String> {
+ let header_infos = match Self::parse_header_infos(req) {
+ Ok(infos) => infos,
+ Err(message) => return error_handler_internal(message),
+ };
+
+ let body = format!("Rest: {}", path_rest);
+ Response::builder().body(body.to_string()).unwrap()
+ }
+
+
+ fn check<'a>(&self, state: &super::ApplicationState, req: &Request<Bytes>, path_rest: &'a str) -> Response<String> {
+ let header_infos = match Self::parse_header_infos(req) {
+ Ok(infos) => infos,
+ Err(message) => return error_handler_internal(message),
+ };
+ if is_logged_in(&header_infos.cookies, &state.cookie_store) {
+ make_response(StatusCode::OK, "".to_string())
+ } else {
+ make_response(StatusCode::UNAUTHORIZED, "Cookie expired".to_string())
+ }
+ }
+
+
+ fn parse_header_infos(req: &Request<Bytes>) -> Result<HeaderExtract, String> {
+ let mut totp_secrets = Vec::new();
+ for header_value in req.headers().get_all(HTTP_HEADER_X_TOTP_SECRET) {
+ let value = header_value.to_str().or(Err("Failed to read totp-secret header value"))?;
+ totp_secrets.push(value);
+ }
+
+ let mut cookies = Vec::new();
+ for header_value in req.headers().get_all(::http::header::COOKIE) {
+ let value = header_value.to_str().or(Err("Failed to read cookie value"))?;
+ let cookie = Cookie::parse(value).or(Err("Failed to parse cookie value"))?;
+ cookies.push(cookie);
+ }
+
+ Ok(HeaderExtract { totp_secrets, cookies })
+ }
+}
+
+#[cfg(test)]
+mod test1 {
+ // use super::*;
+ use test::Bencher;
+ // use horrorshow::prelude::*;
+ use horrorshow::helper::doctype;
+
+ #[bench]
+ fn bench_1(_: &mut Bencher) {
+ let _ = format!("{}", html! {
+: doctype::HTML;
+html {
+head {
+title: "Hello world!";
+}
+body {
+// attributes
+h1(id = "heading") {
+// Insert escaped text
+: "Hello! This is <html />"
+}
+}
+}
+});
+ }
+}
diff --git a/src/auth_handler/urls.rs b/src/auth_handler/urls.rs
new file mode 100644
index 0000000..fa7c76f
--- /dev/null
+++ b/src/auth_handler/urls.rs
@@ -0,0 +1,18 @@
+use ::router;
+
+#[derive(Clone, Copy)]
+pub(in auth_handler) enum Route {
+ Login,
+ Logout,
+ Info,
+ Check
+}
+
+pub(in auth_handler) fn create_routing_table() -> router::RoutingTable<Route> {
+ let mut r = router::RoutingTable::new();
+ r.insert("/info", Route::Info);
+ r.insert("/login", Route::Login);
+ r.insert("/logout", Route::Logout);
+ r.insert("/check", Route::Check);
+ r
+} \ No newline at end of file
diff --git a/src/cookie_store.rs b/src/cookie_store.rs
new file mode 100644
index 0000000..a77bb14
--- /dev/null
+++ b/src/cookie_store.rs
@@ -0,0 +1,133 @@
+use evmap;
+use std::sync::{Arc, Mutex, MutexGuard};
+use evmap::{WriteHandle, ReadHandle};
+use std::time;
+use std::str;
+use std::hash;
+
+use random;
+use random::Source;
+
+// cookie is a 64-byte printable-characters-only array
+pub struct CookieKey([u8; 64]);
+
+impl PartialEq for CookieKey {
+ fn eq(&self, other: &CookieKey) -> bool {
+ self.0[..] == other.0[..]
+ }
+}
+
+impl Eq for CookieKey {}
+
+impl hash::Hash for CookieKey {
+ fn hash<H: hash::Hasher>(&self, state: &mut H) {
+ for i in self.0.iter() {
+ state.write_u8(*i)
+ }
+ }
+}
+
+impl Clone for CookieKey {
+ fn clone(&self) -> CookieKey {
+ CookieKey(self.0)
+ }
+}
+
+impl ToString for CookieKey {
+ fn to_string(&self) -> String {
+ unsafe { str::from_utf8_unchecked(&self.0).to_string() }
+ }
+}
+
+pub struct CookieStore {
+ pub reader: ReadHandle<CookieKey, u64>,
+ pub writer: Arc<Mutex<WriteHandle<CookieKey, u64>>>,
+}
+
+pub fn to_cookie(data: &str) -> Option<CookieKey> {
+ if data.len() == 64 {
+ let mut cookie_key: [u8; 64] = [0; 64];
+ cookie_key.copy_from_slice(data.as_bytes());
+ Some(CookieKey(cookie_key))
+ } else {
+ None
+ }
+}
+
+static HEXTABLE: [u8; 16] = [b'0', b'1', b'2', b'3', b'4', b'5', b'6', b'7', b'8', b'9',
+ b'A', b'B', b'C', b'D', b'E', b'F'];
+
+
+impl Clone for CookieStore {
+ fn clone(&self) -> Self {
+ CookieStore {
+ reader: self.reader.clone(),
+ writer: self.writer.clone(),
+ }
+ }
+}
+
+impl CookieStore {
+ pub fn new() -> CookieStore {
+ let (r, w) = evmap::new::<CookieKey, u64>();
+ CookieStore {
+ reader: r,
+ writer: Arc::new(Mutex::new(w)),
+ }
+ }
+
+ pub fn create_authenticated_cookie(&self) -> CookieKey {
+ let mut r = random::default();
+ let mut key = [0; 64];
+ for it in key.iter_mut() {
+ let random: u8 = r.read();
+ let value = HEXTABLE[(random & 0x0f) as usize];
+ *it = value;
+ }
+
+ let timeout = time::SystemTime::now() + time::Duration::from_secs(60 * 60 * 24); // 1 day
+ let timeout = timeout.duration_since(time::SystemTime::UNIX_EPOCH).unwrap().as_secs();
+ {
+ let mut writer = self.write_handle();
+ writer.insert(CookieKey(key), timeout);
+ warn!("Insert: {}", CookieKey(key).to_string());
+ writer.refresh();
+ }
+ CookieKey(key)
+ }
+
+
+ fn write_handle(&self) -> MutexGuard<WriteHandle<CookieKey, u64>> {
+ self.writer.lock().unwrap()
+ }
+
+ fn now_unix_epoch() -> u64 {
+ time::SystemTime::now()
+ .duration_since(time::SystemTime::UNIX_EPOCH).unwrap().as_secs()
+ }
+
+ /// true -> cookie is valid until time
+ /// false -> cookie is outdated
+ pub fn is_cookie_authenticated(&self, key: &CookieKey) -> bool {
+ let reader = &self.reader;
+ let value = reader.get_and(key, |v| v[0]);
+
+ warn!("Reading {} -> {:?}", key.to_string(), value);
+ if value.is_none() {
+ false
+ } else if value.unwrap() < Self::now_unix_epoch() {
+ // outdated, remove from map
+ let mut writer = self.write_handle();
+ writer.empty(key.clone());
+ // but no refresh - it's not urgent
+ false
+ } else {
+ true
+ }
+ }
+
+ pub fn clean_outdated_cookies(&self) {
+// unimplemented!()
+ }
+}
+
diff --git a/src/http_server.rs b/src/http_server.rs
new file mode 100644
index 0000000..826163c
--- /dev/null
+++ b/src/http_server.rs
@@ -0,0 +1,298 @@
+use std::fmt;
+use std::io;
+use std::net::SocketAddr;
+use std::sync::Arc;
+use std::boxed::Box;
+use bytes::Bytes;
+
+use tokio;
+use tokio::net::TcpListener;
+use tokio::prelude::*;
+use tokio::codec::{Encoder, Decoder};
+use tokio_threadpool::Builder;
+use tokio_executor::enter;
+use bytes::BytesMut;
+use http::header::HeaderValue;
+use http::{Request, Response};
+use thread_local::ThreadLocal;
+
+use system;
+
+pub trait HttpHandler<T> {
+ fn respond(&self, state: &T, req: Request<Bytes>) -> Response<String>;
+}
+
+pub fn serve<
+ T: 'static + Send + Clone + HttpHandler<X>,
+ X: Send + Clone + 'static
+>(addr: SocketAddr, state: X, handler: T) {
+ let listener = TcpListener::bind(&addr).expect("failed to bind");
+ info!("Listening on: {}", addr);
+ let tl_handler: Arc<ThreadLocal<T>> = Arc::new(ThreadLocal::new());
+ let tl_state: Arc<ThreadLocal<X>>= Arc::new(ThreadLocal::new());
+
+ let program =
+ listener.incoming()
+ .map_err(|e| error!("failed to accept socket; error = {:?}", e))
+ .for_each(move |socket| {
+ let peer_addr = match socket.peer_addr() {
+ Ok(addr) => format!("{}", addr),
+ Err(_) => "<error>".to_string(),
+ };
+
+ let (tx, rx) =
+ HttpFrame.framed(socket).split();
+
+ let tl_handler = tl_handler.clone();
+ let handler = handler.clone();
+
+ let tl_state = tl_state.clone();
+ let state = state.clone();
+
+ let rx_task = rx.and_then(move |req| {
+ let state = state.clone();
+ let state = tl_state.get_or(||{
+ debug!("Clone state");
+ Box::new(state.clone())
+ });
+ let handler = tl_handler.get_or(|| {
+ debug!("Clone handler");
+ Box::new(handler.clone())
+ });
+ info!("{:?} {} {} {:?}", peer_addr, req.method(), req.uri(), req.version());
+ let response = handler.respond(&state, req);
+ Box::new(future::ok(response))
+ });
+ let tx_task = tx.send_all(rx_task)
+ .then(|res| {
+ if let Err(e) = res {
+ error!("failed to process connection; error = {:?}", e);
+ }
+ Ok(())
+ });
+
+ // Spawn the task that handles the connection.
+ tokio::spawn(tx_task);
+ Ok(())
+ });
+
+
+ let mut builder = Builder::new();
+ let runtime = builder
+ .name_prefix("httpd-")
+ .after_start(|| {
+ debug!("Start new worker");
+ system::initialize_rng_from_time();
+ })
+ .build();
+ runtime.spawn(program);
+ enter().expect("nested tokio::run")
+ .block_on(runtime.shutdown_on_idle())
+ .unwrap();
+}
+
+///
+/// The following code is mostly copied from:
+/// https://github.com/tokio-rs/tokio/blob/master/examples/tinyhttp.rs
+///-------------------------------------------------------------------------------------------------
+struct HttpFrame;
+
+/// Implementation of encoding an HTTP response into a `BytesMut`, basically
+/// just writing out an HTTP/1.1 response.
+impl Encoder for HttpFrame {
+ type Item = Response<String>;
+ type Error = io::Error;
+
+ fn encode(&mut self, item: Response<String>, dst: &mut BytesMut) -> io::Result<()> {
+ use std::fmt::Write;
+
+ write!(BytesWrite(dst), "\
+ HTTP/1.1 {}\r\n\
+ Server: nginx-auth-totp\r\n\
+ Content-Length: {}\r\n\
+ Date: {}\r\n\
+ ", item.status(), item.body().len(), date::now()).unwrap();
+
+ for (k, v) in item.headers() {
+ dst.extend_from_slice(k.as_str().as_bytes());
+ dst.extend_from_slice(b": ");
+ dst.extend_from_slice(v.as_bytes());
+ dst.extend_from_slice(b"\r\n");
+ }
+
+ dst.extend_from_slice(b"\r\n");
+ dst.extend_from_slice(item.body().as_bytes());
+
+ return Ok(());
+
+ // Right now `write!` on `Vec<u8>` goes through io::Write and is not
+ // super speedy, so inline a less-crufty implementation here which
+ // doesn't go through io::Error.
+ struct BytesWrite<'a>(&'a mut BytesMut);
+
+ impl<'a> fmt::Write for BytesWrite<'a> {
+ fn write_str(&mut self, s: &str) -> fmt::Result {
+ self.0.extend_from_slice(s.as_bytes());
+ Ok(())
+ }
+
+ fn write_fmt(&mut self, args: fmt::Arguments) -> fmt::Result {
+ fmt::write(self, args)
+ }
+ }
+ }
+}
+
+/// Implementation of decoding an HTTP request from the bytes we've read so far.
+/// This leverages the `httparse` crate to do the actual parsing and then we use
+/// that information to construct an instance of a `http::Request` object,
+/// trying to avoid allocations where possible.
+impl Decoder for HttpFrame {
+ type Item = Request<Bytes>;
+ type Error = io::Error;
+
+ fn decode(&mut self, src: &mut BytesMut) -> io::Result<Option<Request<Bytes>>> {
+ // TODO: we should grow this headers array if parsing fails and asks
+ // for more headers
+ let mut headers = [None; 16];
+ let (method, path, version, amt) = {
+ let mut parsed_headers = [httparse::EMPTY_HEADER; 16];
+ let mut r = httparse::Request::new(&mut parsed_headers);
+ let status = r.parse(src).map_err(|e| {
+ let msg = format!("failed to parse http request: {:?}", e);
+ io::Error::new(io::ErrorKind::Other, msg)
+ })?;
+
+ let amt = match status {
+ httparse::Status::Complete(amt) => amt,
+ httparse::Status::Partial => return Ok(None),
+ };
+
+ let toslice = |a: &[u8]| {
+ let start = a.as_ptr() as usize - src.as_ptr() as usize;
+ assert!(start < src.len());
+ (start, start + a.len())
+ };
+
+ for (i, header) in r.headers.iter().enumerate() {
+ let k = toslice(header.name.as_bytes());
+ let v = toslice(header.value);
+ headers[i] = Some((k, v));
+ }
+
+ (toslice(r.method.unwrap().as_bytes()),
+ toslice(r.path.unwrap().as_bytes()),
+ r.version.unwrap(),
+ amt)
+ };
+ if version != 1 && version != 0 { // TODO
+ error!("Version: {}", version);
+ return Err(io::Error::new(io::ErrorKind::Other, "only HTTP/1.1 accepted"));
+ }
+ let data = src.split_to(amt).freeze();
+ let mut req_builder = Request::builder();
+ req_builder.method(&data[method.0..method.1]);
+ req_builder.uri(data.slice(path.0, path.1));
+ req_builder.version(http::Version::HTTP_11);
+ for header in headers.iter() {
+ let (k, v) = match *header {
+ Some((ref k, ref v)) => (k, v),
+ None => break,
+ };
+ let value = unsafe {
+ HeaderValue::from_shared_unchecked(data.slice(v.0, v.1))
+ };
+ req_builder.header(&data[k.0..k.1], value);
+ }
+
+
+ let request_body = src.split_off(0).freeze();
+ let req = req_builder.body(request_body).map_err(|e| {
+ io::Error::new(io::ErrorKind::Other, e)
+ })?;
+ Ok(Some(req))
+ }
+}
+
+mod date {
+ use std::cell::RefCell;
+ use std::fmt::{self, Write};
+ use std::str;
+
+ use time::{self, Duration};
+
+ pub struct Now(());
+
+ /// Returns a struct, which when formatted, renders an appropriate `Date`
+ /// header value.
+ pub fn now() -> Now {
+ Now(())
+ }
+
+ // Gee Alex, doesn't this seem like premature optimization. Well you see
+ // there Billy, you're absolutely correct! If your server is *bottlenecked*
+ // on rendering the `Date` header, well then boy do I have news for you, you
+ // don't need this optimization.
+ //
+ // In all seriousness, though, a simple "hello world" benchmark which just
+ // sends back literally "hello world" with standard headers actually is
+ // bottlenecked on rendering a date into a byte buffer. Since it was at the
+ // top of a profile, and this was done for some competitive benchmarks, this
+ // module was written.
+ //
+ // Just to be clear, though, I was not intending on doing this because it
+ // really does seem kinda absurd, but it was done by someone else [1], so I
+ // blame them! :)
+ //
+ // [1]: https://github.com/rapidoid/rapidoid/blob/f1c55c0555007e986b5d069fe1086e6d09933f7b/rapidoid-commons/src/main/java/org/rapidoid/commons/Dates.java#L48-L66
+
+ struct LastRenderedNow {
+ bytes: [u8; 128],
+ amt: usize,
+ next_update: time::Timespec,
+ }
+
+ thread_local!(static LAST: RefCell<LastRenderedNow> = RefCell::new(LastRenderedNow {
+ bytes: [0; 128],
+ amt: 0,
+ next_update: time::Timespec::new(0, 0),
+ }));
+
+ impl fmt::Display for Now {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ LAST.with(|cache| {
+ let mut cache = cache.borrow_mut();
+ let now = time::get_time();
+ if now >= cache.next_update {
+ cache.update(now);
+ }
+ f.write_str(cache.buffer())
+ })
+ }
+ }
+
+ impl LastRenderedNow {
+ fn buffer(&self) -> &str {
+ str::from_utf8(&self.bytes[..self.amt]).unwrap()
+ }
+
+ fn update(&mut self, now: time::Timespec) {
+ self.amt = 0;
+ write!(LocalBuffer(self), "{}", time::at(now).rfc822()).unwrap();
+ self.next_update = now + Duration::seconds(1);
+ self.next_update.nsec = 0;
+ }
+ }
+
+ struct LocalBuffer<'a>(&'a mut LastRenderedNow);
+
+ impl<'a> fmt::Write for LocalBuffer<'a> {
+ fn write_str(&mut self, s: &str) -> fmt::Result {
+ let start = self.0.amt;
+ let end = start + s.len();
+ self.0.bytes[start..end].copy_from_slice(s.as_bytes());
+ self.0.amt += s.len();
+ Ok(())
+ }
+ }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..bb3c57e
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,106 @@
+#![feature(test)]
+#![feature(convert_id)]
+#![feature(proc_macro_hygiene)]
+#![feature(try_from)]
+#![feature(duration_as_u128)]
+#![feature(libc)]
+
+use std::env;
+use std::sync::Arc;
+use std::thread;
+use std::sync::atomic;
+use std::net::SocketAddr;
+
+extern crate ascii;
+extern crate getopts;
+#[macro_use]
+extern crate log;
+extern crate tokio;
+extern crate tokio_threadpool;
+extern crate tokio_executor;
+extern crate time;
+extern crate simple_logger;
+extern crate oath;
+extern crate evmap;
+extern crate test;
+#[macro_use]
+extern crate horrorshow;
+extern crate random;
+extern crate http;
+extern crate httparse;
+extern crate bytes;
+extern crate thread_local;
+extern crate cookie;
+extern crate url;
+
+use getopts::Options;
+use log::LogLevel::{Debug, Warn};
+use time::Duration;
+
+mod auth_handler;
+mod cookie_store;
+mod http_server;
+mod router;
+mod system;
+mod totp;
+
+extern crate libc;
+
+use cookie_store::CookieStore;
+
+#[derive(Clone)]
+pub struct ApplicationState {
+ cookie_store: CookieStore,
+ cookie_max_age: Duration,
+}
+
+fn print_usage(program: &str, opts: &Options) {
+ let brief = format!("Usage: {} [options]", program);
+ print!("{}", opts.usage(&brief));
+}
+
+fn main() {
+ let args: Vec<String> = env::args().collect();
+ let program = args[0].clone();
+ let mut opts = Options::new();
+ opts.optopt("l", "port", "Listen address", "LISTEN-ADDR");
+ opts.optflag("d", "debug", "Use loglevel Debug instead of Warn");
+ opts.optflag("h", "help", "print this help menu");
+ let matches = opts.parse(&args[1..]).unwrap_or_else(|f| panic!(f.to_string()));
+
+ if matches.opt_present("h") {
+ print_usage(&program, &opts);
+ return;
+ }
+
+ simple_logger::init_with_level(if matches.opt_present("d") { Debug } else { Warn })
+ .unwrap_or_else(|_| panic!("Failed to initialize logger"));
+
+
+ let addr = matches.opt_str("l").unwrap_or_else(||"127.0.0.1:8080".to_string());
+ let addr = addr.parse::<SocketAddr>()
+ .unwrap_or_else(|_| panic!("Failed to parse LISTEN-ADDRESS"));
+
+
+ // concurrent eventual consistent hashmap with <cookie-id, timeout>
+ let state = ApplicationState { cookie_store: CookieStore::new(), cookie_max_age: Duration::days(1) };
+
+ let server_shutdown_condvar = Arc::new(atomic::AtomicBool::new(false));
+
+ let cookie_clean_thread_condvar = server_shutdown_condvar.clone();
+ let cookie_clean_state = state.clone();
+ let cookie_clean_thread = thread::spawn(move || {
+ while !cookie_clean_thread_condvar.load(atomic::Ordering::Relaxed) {
+ thread::sleep(std::time::Duration::from_secs(60));
+ debug!("Clean cookie cache");
+ cookie_clean_state.cookie_store.clean_outdated_cookies();
+ }
+ });
+
+ let auth_handler = auth_handler::AuthHandler::make();
+ http_server::serve(addr, state, auth_handler);
+
+ server_shutdown_condvar.store(true, atomic::Ordering::Relaxed);
+ debug!("Waiting for cleanup thread to shutdown");
+ cookie_clean_thread.join().unwrap();
+}
diff --git a/src/router.rs b/src/router.rs
new file mode 100644
index 0000000..18ed429
--- /dev/null
+++ b/src/router.rs
@@ -0,0 +1,162 @@
+use std::collections::VecDeque;
+
+#[derive(Clone, Copy)]
+enum TablePointer<R> where R: Clone + Copy {
+ Link(usize),
+ Route(R),
+ RouteWithLink(usize, R),
+ NotFound,
+}
+
+pub struct NoMatchingRoute;
+
+pub type RouteMatch<'a, R> = Result<(R, &'a str), NoMatchingRoute>;
+
+#[derive(Clone)]
+pub struct RoutingTable<R> where R: Copy {
+ tables: Vec<[TablePointer<R>; 128]>,
+}
+
+impl<R> RoutingTable<R> where R: Copy {
+ pub fn new() -> RoutingTable<R> {
+ let zero_table = [TablePointer::NotFound; 128];
+ RoutingTable {
+ tables: vec![zero_table],
+ }
+ }
+
+ pub fn insert(&mut self, path: &str, route: R) {
+ assert!(!path.is_empty());
+ let mut index: usize = 0;
+ let p: Vec<usize> = path.as_bytes().iter().map(|i| usize::from(*i)).collect();
+ let mut path_queue = VecDeque::from(p);
+
+ while path_queue.len() > 1 {
+ let ch = path_queue.pop_front().unwrap() as usize % 128;
+ match self.tables[index][ch] {
+ TablePointer::NotFound => {
+ self.tables.push([TablePointer::NotFound; 128]);
+ let i_other_table = self.tables.len() - 1;
+ self.tables[index][ch] = TablePointer::Link(i_other_table);
+ path_queue.push_front(ch);
+ }
+ TablePointer::Link(i_other_table) => {
+ index = i_other_table as usize;
+ }
+ TablePointer::RouteWithLink(i_other_table, _) => {
+ index = i_other_table as usize;
+ path_queue.push_front(ch);
+ }
+ TablePointer::Route(route) => {
+ self.tables.push([TablePointer::NotFound; 128]);
+ let i_other_table = self.tables.len() - 1;
+ self.tables[index][ch] = TablePointer::RouteWithLink(i_other_table, route);
+ path_queue.push_front(ch);
+ }
+ }
+ }
+ // last character element in path
+ let ch = path_queue.pop_front().unwrap() % 128;
+ match self.tables[index][ch] {
+ TablePointer::NotFound => {
+ // slot is empty, just place the Route
+ self.tables[index][ch] = TablePointer::Route(route)
+ }
+ TablePointer::Link(i) => {
+ // slot is filled with link to longer path. Replace by RouteWithLink
+ self.tables[index][ch] = TablePointer::RouteWithLink(i, route);
+ }
+ _ => {
+ panic!("Not expected here, maybe duplicate")
+ }
+ }
+ }
+
+ pub fn match_path<'a>(&self, path: &'a str) -> RouteMatch<'a, R> {
+ let mut table: &[TablePointer<R>; 128] = &self.tables[0];
+ let path_bytes = path.as_bytes();
+ let path_max_i = path_bytes.len() - 1;
+
+ let mut i = 0;
+ while i <= path_max_i {
+ let ch = path_bytes[i] as usize % 128;
+ match table[ch] {
+ TablePointer::NotFound => {
+ return Err(NoMatchingRoute);
+ }
+ TablePointer::Link(i_other_table) => {
+ table = &self.tables[i_other_table];
+ i += 1;
+ }
+ TablePointer::RouteWithLink(i_other_table, route) => {
+ if i == path_max_i {
+ return Ok((route, Default::default()));
+ } else {
+ table = &self.tables[i_other_table];
+ }
+ }
+ TablePointer::Route(route) => {
+ return Ok((route, path.get(i + 1..=path_max_i).unwrap()));
+ }
+ }
+ }
+ Err(NoMatchingRoute)
+ }
+}
+
+#[cfg(test)]
+mod test1 {
+ use super::*;
+ use test::Bencher;
+
+ #[derive(Clone, Copy)]
+ enum Route {
+ Login,
+ Logout,
+ Info,
+ Check
+ }
+
+ #[bench]
+ fn bench_1(b: &mut Bencher) {
+ let mut r = RoutingTable::new();
+ r.insert("/login", Route::Login);
+ r.insert("/info", Route::Info);
+ r.insert("/logout", Route::Logout);
+ r.insert("/logout2", Route::Info);
+ r.insert("/check", Route::Check);
+
+ match r.tables[0][47] {
+ TablePointer::Link(n) => assert!(n == 1),
+ _ => panic!("Wrong"),
+ }
+
+ b.iter(|| {
+ match r.match_path("/login") {
+ RouteMatch::Match(Route::Login, rest) => assert_eq!(rest, ""),
+ _ => panic!("Wrong")
+ }
+ match r.match_path("/logout") {
+ RouteMatch::Match(Route::Logout, rest) => assert_eq!(rest, ""),
+ _ => panic!("Wrong")
+ }
+ match r.match_path("/info") {
+ RouteMatch::Match(Route::Info, rest) => assert_eq!(rest, ""),
+ _ => panic!("Wrong")
+ }
+ match r.match_path("/logout2") {
+ RouteMatch::Match(Route::Info, rest) => assert_eq!(rest, ""),
+ _ => panic!("Wrong")
+ }
+ match r.match_path("/asdasdasd") {
+ RouteMatch::None => (),
+ _ => panic!("Wrong")
+ }
+
+ match r.match_path("/login/foo/bar") {
+ RouteMatch::Match(Route::Login, rest) => assert_eq!(rest, "/foo/bar"),
+ _ => panic!("Wrong")
+ }
+ })
+ }
+} \ No newline at end of file
diff --git a/src/system.rs b/src/system.rs
new file mode 100644
index 0000000..8eba50c
--- /dev/null
+++ b/src/system.rs
@@ -0,0 +1,8 @@
+use std::time;
+
+pub fn initialize_rng_from_time() {
+ let r = random::default();
+ let now = time::SystemTime::now();
+ let nano_secs = now.duration_since(time::SystemTime::UNIX_EPOCH).unwrap().as_nanos();
+ r.seed([(nano_secs >> 64) as u64, nano_secs as u64]);
+}
diff --git a/src/totp.rs b/src/totp.rs
new file mode 100644
index 0000000..09b4503
--- /dev/null
+++ b/src/totp.rs
@@ -0,0 +1,30 @@
+use oath::totp_custom_time;
+use oath::HashType;
+use std::time::{UNIX_EPOCH, SystemTime};
+
+pub fn verify(secret: &str, token: &str) -> Result<bool, &'static str> {
+ let time_step = 30;
+ let totp = |time| {
+ totp_custom_time(secret, 6, 0, time_step, time, &HashType::SHA512)
+ .map(|t| {
+ debug!("Generated OTP for probing {} for key {}", t, secret);
+ t
+ })
+ .map(|t| format!("{:06}", t) == *token)
+ };
+ let current_time: u64 = SystemTime::now().duration_since(UNIX_EPOCH)
+ .expect("Earlier than 1970-01-01 00:00:00 UTC").as_secs();
+ if current_time % time_step <= 5 && totp(current_time - 30)? {
+ return Ok(true);
+ }
+
+ if current_time % time_step >= 25 && totp(current_time + 30)? {
+ return Ok(true);
+ }
+
+ if totp(current_time)? {
+ return Ok(true);
+ }
+
+ Ok(false)
+} \ No newline at end of file
diff --git a/test/etc/nginx.conf b/test/etc/nginx.conf
new file mode 100644
index 0000000..3477353
--- /dev/null
+++ b/test/etc/nginx.conf
@@ -0,0 +1,35 @@
+# nginx -p . -c nginx.conf
+
+pid /tmp/nginx.example.pid;
+
+daemon off;
+
+events {
+ worker_connections 5;
+}
+
+http {
+ access_log /dev/stdout;
+ error_log /dev/stderr;
+
+ server {
+ server_name localhost;
+
+ location /auth {
+ rewrite /auth/(.+) /$1 break;
+ proxy_pass http://127.0.0.1:8080; # This is the TOTP Server
+ proxy_set_header X-Totp-Secret baadf00d;
+ proxy_set_header X-Totp-Secret deadc0de;
+ }
+
+ # This ensures that if the TOTP server returns 401 we redirect to login
+ error_page 401 = @error401;
+ location @error401 {
+ return 302 /auth/login$request_uri;
+ }
+
+ location / {
+ auth_request /auth/check;
+ }
+ }
+} \ No newline at end of file
diff --git a/test/nginx.sh b/test/nginx.sh
new file mode 100755
index 0000000..d6926c0
--- /dev/null
+++ b/test/nginx.sh
@@ -0,0 +1,18 @@
+#!/bin/sh
+set -x
+
+bwrap \
+ --ro-bind /bin /bin \
+ --ro-bind /usr /usr \
+ --ro-bind /etc /etc \
+ --ro-bind /lib /lib \
+ --ro-bind /lib64 /lib64 \
+ --ro-bind /run /run \
+ --ro-bind etc /etc/nginx \
+ --ro-bind www /usr/share/nginx/html \
+ --dev /dev \
+ --proc /proc \
+ --dir /tmp \
+ --dir /var/log/nginx \
+ --dir /var/lib/nginx \
+ /usr/sbin/nginx \ No newline at end of file
diff --git a/test/oathtool.sh b/test/oathtool.sh
new file mode 100755
index 0000000..d1d3441
--- /dev/null
+++ b/test/oathtool.sh
@@ -0,0 +1,6 @@
+#!/bin/sh
+
+for key in baadf00d deadc0de; do
+ echo -n "$key: "
+ oathtool --totp=sha512 $key
+done \ No newline at end of file
diff --git a/test/www/index.html b/test/www/index.html
new file mode 100644
index 0000000..3ad444a
--- /dev/null
+++ b/test/www/index.html
@@ -0,0 +1,58 @@
+<!DOCTYPE html>
+<html><div id="saka-gui-root" style="position: absolute; left: 0px; top: 0px; width: 100%; height: 100%; z-index: 2147483647; opacity: 1; pointer-events: none;"><div><div><style>
+@font-face {
+ font-family: Roboto; -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased;
+ font-style: normal; font-weight: normal; src: url(moz-extension://727c00b7-4391-4fd7-82e7-3d51bb518a34/Roboto-Regular.tff) format('tff');
+}
+.saka-hint-body {
+ all: initial;
+position: absolute;
+z-index: 2147483647;
+opacity: 0.85;
+font-family: Roboto, sans-serif;
+font-weight: 900;
+padding: 0.15rem 0.25rem;
+border: 0px solid;
+text-align: center;
+text-decoration: none;
+text-transform: uppercase;
+vertical-align: middle;
+font-size: 12px;
+color: #3ff5d5;
+background-color: #000000;
+border-color: #ff0000;
+box-shadow: 0 3px 1px -2px rgba(0,0,0,.2), 0 2px 2px 0 rgba(0,0,0,.14), 0 1px 5px 0 rgba(0,0,0,.12);
+border-radius: 4px;
+transform: translate3d(0%, -50%, 0)
+}
+.saka-hint-normal-char {
+
+}
+.saka-hint-active-char {
+ opacity: 0.5
+}</style><div style="position: absolute; left: 0px; top: 0px;"></div></div></div></div><head>
+<meta http-equiv="content-type" content="text/html; charset=windows-1252">
+<title>Welcome to nginx!</title>
+<style>
+ body {
+ width: 35em;
+ margin: 0 auto;
+ font-family: Tahoma, Verdana, Arial, sans-serif;
+ }
+</style>
+</head>
+<body>
+<h1>Welcome to nginx!</h1>
+<p>If you see this page, the nginx web server is successfully installed and
+working. Further configuration is required.</p>
+
+<p>For online documentation and support please refer to
+<a href="http://nginx.org/">nginx.org</a>.<br>
+Commercial support is available at
+<a href="http://nginx.com/">nginx.com</a>.</p>
+
+<p><em>Thank you for using nginx.</em></p>
+
+<p><a href="other_page.html">look here other_page.html</a></p>
+
+</body></html> \ No newline at end of file
diff --git a/test/www/other_page.html b/test/www/other_page.html
new file mode 100644
index 0000000..49656db
--- /dev/null
+++ b/test/www/other_page.html
@@ -0,0 +1,57 @@
+<!DOCTYPE html>
+<html><div id="saka-gui-root" style="position: absolute; left: 0px; top: 0px; width: 100%; height: 100%; z-index: 2147483647; opacity: 1; pointer-events: none;"><div><div><style>
+@font-face {
+ font-family: Roboto; -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased;
+ font-style: normal; font-weight: normal; src: url(moz-extension://727c00b7-4391-4fd7-82e7-3d51bb518a34/Roboto-Regular.tff) format('tff');
+}
+.saka-hint-body {
+ all: initial;
+position: absolute;
+z-index: 2147483647;
+opacity: 0.85;
+font-family: Roboto, sans-serif;
+font-weight: 900;
+padding: 0.15rem 0.25rem;
+border: 0px solid;
+text-align: center;
+text-decoration: none;
+text-transform: uppercase;
+vertical-align: middle;
+font-size: 12px;
+color: #3ff5d5;
+background-color: #000000;
+border-color: #ff0000;
+box-shadow: 0 3px 1px -2px rgba(0,0,0,.2), 0 2px 2px 0 rgba(0,0,0,.14), 0 1px 5px 0 rgba(0,0,0,.12);
+border-radius: 4px;
+transform: translate3d(0%, -50%, 0)
+}
+.saka-hint-normal-char {
+
+}
+.saka-hint-active-char {
+ opacity: 0.5
+}</style><div style="position: absolute; left: 0px; top: 0px;"></div></div></div></div><head>
+<meta http-equiv="content-type" content="text/html; charset=windows-1252">
+<title>Welcome to nginx!</title>
+<style>
+ body {
+ width: 35em;
+ margin: 0 auto;
+ font-family: Tahoma, Verdana, Arial, sans-serif;
+ }
+</style>
+</head>
+<body>
+<h1>Welcome to nginx!</h1>
+<p>If you see this page, the nginx web server is successfully installed and
+working. Further configuration is required.</p>
+
+<p>For online documentation and support please refer to
+<a href="http://nginx.org/">nginx.org</a>.<br>
+Commercial support is available at
+<a href="http://nginx.com/">nginx.com</a>.</p>
+
+<p><em>Thank you for using nginx.</em></p>
+
+
+</body></html> \ No newline at end of file