From 39bcb09a367251bed7cfb445f546252547058e66 Mon Sep 17 00:00:00 2001 From: Freya Murphy Date: Thu, 30 May 2024 13:05:46 -0400 Subject: [PATCH] many changes --- README.md | 21 +--- conf/ldap/ldap.env | 10 -- docker-compose.yml | 2 +- docker.env | 37 ++++++ src/public/main.css | 2 +- src/web/helpers/auth.php | 148 ++++++++++++----------- src/web/helpers/ldap.php | 137 ++++++++++++++++----- src/web/helpers/schema.php | 236 +++++++++++++++++++++++++++++++++++++ src/web/index.php | 51 +------- src/web/router.php | 154 ++++++++++++++++++++++++ 10 files changed, 616 insertions(+), 182 deletions(-) delete mode 100644 conf/ldap/ldap.env create mode 100644 docker.env create mode 100644 src/web/helpers/schema.php create mode 100644 src/web/router.php diff --git a/README.md b/README.md index a46a587..06eb386 100644 --- a/README.md +++ b/README.md @@ -4,26 +4,7 @@ LDAP Forward Auth is a forward auth service (shocking) that i made to use with t It allows a proxied login page to appear if a user tries to access restricted content while not logged in. -It uses LDAP for authentication, which can be configured in the conf/ldap/ldap.env file. - -``` -# the following is all the ldap credentials for a ldap bind dn auth setup -# this does not support ldap simple auth -LDAP_URL= -LDAP_BIND_DN= -LDAP_BIND_PASSWORD= -LDAP_BASE_DN= -LDAP_FILTER="(&)" -LDAP_UID="cn" - -# the host that the forward auth is hosted at -# if the user is not logged in they will be redirected here -HTTP_HOST=auth.example.com - -# the base domain for all websites that are being authed checked including the forward auth itself. -# they all need to have a common root domain otherwise the X-LDAP-Auth-Key cookie cannot be set. -COOKIE_DOMAIN=example.com -``` +It uses LDAP for authentication, which can be configured in the `docker.env` file. Once authenticated, sets the X-Webauth-User header, which can be used by applications to see who is logged in. diff --git a/conf/ldap/ldap.env b/conf/ldap/ldap.env deleted file mode 100644 index 29f55c0..0000000 --- a/conf/ldap/ldap.env +++ /dev/null @@ -1,10 +0,0 @@ - -LDAP_URL= -LDAP_BIND_DN= -LDAP_BIND_PASSWORD= -LDAP_BASE_DN= -LDAP_FILTER="(&)" -LDAP_UID="cn" - -HTTP_HOST=auth.example.com -COOKIE_DOMAIN=example.com diff --git a/docker-compose.yml b/docker-compose.yml index 8c35bad..4e1f3cd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: build: ./build/php restart: unless-stopped env_file: - - ./conf/ldap/ldap.env + - docker.env volumes: - ./src:/opt/website:ro - ./data/session:/var/lib/php/session diff --git a/docker.env b/docker.env new file mode 100644 index 0000000..c14d431 --- /dev/null +++ b/docker.env @@ -0,0 +1,37 @@ + +# +# ldap_forwardauth config file +# + +# == LDAP SETTINGS == +# Enter the auth information for the ldap bind dn auth +LDAP_URL="ldap://127.0.0.1" +LDAP_BIND_DN="cn=example,ou=users,dc=example,dc=com" +LDAP_BIND_PASSWORD="securePassword" +LDAP_BASE_DN="ou=users,dc=example,dc=com" +LDAP_FILTER="(&)" +LDAP_UID="cn" + +# == LDAP MATCHERS == +# Matchers allow the program to figure out what fields +# contain what type of data +LDAP_USERNAME_MATCHER="uid" +LDAP_EMAIL_MATCHER="mail" +LDAP_FIRST_NAME_MATCHER="givenname" +LDAP_LAST_NAME_MATCHER="sn" + +# == FORWARD AUTH HEADER NAMES == +# Specify the names for the forward auth headers +# ldap_forwardauth should output +HTTP_USERNAME_HEADER=X-Webauth-Username +HTTP_EMAIL_HEADER=X-Webauth-Email +HTTP_FIRST_NAME_HEADER=X-Webauth-First-Name +HTTP_LAST_NAME_HEADER=X-Webauth-Last-Name + +# == HOST INFORMATION == +# The http host is the domain and port ldap_forwardauth is hosted at +# Cookie domain is the superset of domains that ldap_forwardauth can auth to +# Note: HTTPH_HOST must must be the same or a subdomain to COOKIE_DOMAIN +HTTP_HOST=auth.example.com +COOKIE_DOMAIN=example.com +COOKIE_NAME=X-Webauth-Token diff --git a/src/public/main.css b/src/public/main.css index 51dfb4d..2c5ecf2 100644 --- a/src/public/main.css +++ b/src/public/main.css @@ -21,7 +21,7 @@ body { color: #fff; font-family: "Open Sans", Helvetica, Arial, sans-serif; font-weight: 100; - font-size: 12px; + font-size: 14px; } main { diff --git a/src/web/helpers/auth.php b/src/web/helpers/auth.php index 9228706..187f556 100644 --- a/src/web/helpers/auth.php +++ b/src/web/helpers/auth.php @@ -1,85 +1,83 @@ session_lifetime_seconds = 60 * 60 * 24 * 3; } -} -function store_cookie($key) { - $cookie_name = 'X-LDAP-Auth-Key'; - $cookie_options = array ( - 'expires' => time() + 60*60*24*30, - 'path' => '/', - 'domain' => getenv("COOKIE_DOMAIN"), - 'secure' => true, - 'httponly' => true, - 'samesite' => 'None' - ); - setcookie( - $cookie_name, - $key, - $cookie_options - ); -} + /** + * Generate a random token + * @param int $length + */ + private function gen_token(int $length): string { + $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $random = ''; -function load_key($key) { - $file = "/tmp/$key"; - if (!file_exists($file)) - return FALSE; - $content = explode("\n", file_get_contents($file)); - return array( - 'user' => $content[0], - 'time' => $content[1] - ); -} + for ($i = 0; $i < $length; $i++) { + $index = rand(0, strlen($characters) - 1); + $random .= $characters[$index]; + } -function store_key($key, $user) { - $file = "/tmp/$key"; - $now = (string)time(); - $content = "$user\n{$now}"; - file_put_contents($file, $content, LOCK_EX); -} - -function get_random($n) -{ - $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; - $randomString = ''; - - for ($i = 0; $i < $n; $i++) { - $index = rand(0, strlen($characters) - 1); - $randomString .= $characters[$index]; - } - - return $randomString; -} - -function key_auth() { - $key = get_cookie(); - if ($key === FALSE) { - return FALSE; + return $random; + } + + /** + * Saves a user into the session specified by their auth key + * @param Session $session - the session user data + */ + public function save_session(Session $session): void { + $path = "/tmp/{$session->token}"; + $data = json_encode($session->to_array()); + file_put_contents($path, $data, LOCK_EX); + } + + /** + * Loads the auth session associated with a specific key + * @param string $token - the session $key + */ + private function load_session(string $token): ?Session { + try { + $path = "/tmp/$token"; + if (!file_exists($path)) { + return NULL; + } + $content = file_get_contents($path); + $json = json_decode($content, TRUE); + $session = new Session(); + if ($session->from_array($json)) + return NULL; + return $session; + } catch (Exception $e) { + return NULL; + } + } + + /** + * Creates a new session for a user + */ + public function create_session(User $user): Session { + $session = new Session(); + $session->token = $this->gen_token(128); + $session->created = time(); + $session->user = $user; + $session->reset_expiry(); + $this->save_session($session); + return $session; + } + + /** + * Gets the current authed session + */ + public function get_session(): ?Session { + $cookie_name = getenv("COOKIE_NAME"); + if(!isset($_COOKIE[$cookie_name])) { + return NULL; + } + $token = $_COOKIE[$cookie_name]; + return $this->load_session($token); } - $data = load_key($key); - if ($data === FALSE) { - return FALSE; - } - $user = $data['user']; - $time = $data['time']; - $now = time(); - if ($time > $now || $now - $time > 60 * 60 * 24) { - return FALSE; - } - store_key($key, $user); - return $user; -} -function key_new($user) { - $key = get_random(128); - store_key($key, $user); - store_cookie($key); } diff --git a/src/web/helpers/ldap.php b/src/web/helpers/ldap.php index f3697cc..46bbe69 100644 --- a/src/web/helpers/ldap.php +++ b/src/web/helpers/ldap.php @@ -1,41 +1,120 @@ env = array( + # ldap host + 'url' => getenv("LDAP_URL"), + # ldap credentials + 'bind' => getenv("LDAP_BIND_DN"), + 'password' => getenv("LDAP_BIND_PASSWORD"), + # ldap search + 'base' => getenv("LDAP_BASE_DN"), + 'filter' => getenv("LDAP_FILTER"), + 'uid' => getenv("LDAP_UID"), + ); + + $this->matchers = array( + 'username' => getenv("LDAP_USERNAME_MATCHER"), + 'email' => getenv("LDAP_EMAIL_MATCHER"), + 'first_name' => getenv("LDAP_FIRST_NAME_MATCHER"), + 'last_name' => getenv("LDAP_LAST_NAME_MATCHER"), + ); + + $this->bound = NULL; + $this->conn = NULL; } - $search = @ldap_search($conn, $bound, $filter); - - $info = @ldap_get_entries($conn, $search); - $user = NULL; - for ($i=0; $i<$info['count']; $i++) { - $user = $info[$i]; - if (!array_key_exists($uid, $user)) - continue; - if ($user[$uid][0] == $auth_username) - break; + private function connect(): int { + if (($this->conn = @ldap_connect($this->env['url'])) == FALSE) { + $this->conn = NULL; + return 1; + } + @ldap_set_option($this->conn, LDAP_OPT_PROTOCOL_VERSION, 3); + return 0; } - if ($user == NULL) { - return FALSE; + private function rebind(): int { + if ($this->bound != $this->env['bind']) { + return $this->bind( + $this->env['bind'], + $this->env['password']); + } + return 0; + } + + public function bind( + string $dn, + #[\SensitiveParameter] string $password + ): int { + if ($this->conn == NULL && $this->connect()) { + return 1; + } + if (@ldap_bind($this->conn, $dn, $password) == FALSE) { + return 1; + } + $this->bound = $dn; + return 0; + } + + /** + * @param array $user + */ + private function find_entry(array $user, string $field): mixed { + if (!isset($user[$field])) + return NULL; + $data = $user[$field]; + if (is_array($data)) + $data = $data[0]; + return $data; + } + + public function search( + string ...$usernames + ): ?array { + if ($this->rebind()) + return NULL; + + $search = @ldap_search( + $this->conn, + $this->env['base'], + $this->env['filter'] + ); + if ($search == FALSE) + return NULL; + + $info = @ldap_get_entries($this->conn, $search); + + $users = array(); + + for ($i=0; $i<$info['count']; $i++) { + $user_arr = $info[$i]; + $user_data = array ( + 'dn' => $user_arr['dn'], + 'username' => $this->find_entry($user_arr, $this->matchers['username']), + 'email' => $this->find_entry($user_arr, $this->matchers['email']), + 'first_name' => $this->find_entry($user_arr, $this->matchers['first_name']), + 'last_name' => $this->find_entry($user_arr, $this->matchers['last_name']) + ); + $user = new User(); + if ($user->from_array($user_data)) { + continue; + } + if (count($usernames) && !in_array($user->username, $usernames)) { + continue; + } + $users[] = $user; + } + + return $users; } - $succ = @ldap_bind($conn, $user['dn'], $auth_password); - return !!$succ; } diff --git a/src/web/helpers/schema.php b/src/web/helpers/schema.php new file mode 100644 index 0000000..6afa43f --- /dev/null +++ b/src/web/helpers/schema.php @@ -0,0 +1,236 @@ +dn == NULL || + $this->username == NULL || + $this->email == NULL + ) ? 1 : 0; + } + + /** + * Loads Data from the array to self + * @param array $data - the data to load + * @return int 0 on success, 1 on error + */ + public function from_array(array $data): int { + $this->dn = NULL; + $this->username = NULL; + $this->email = NULL; + $this->first_name = NULL; + $this->last_name = NULL; + + foreach ($data as $key => $value) { + if ($value == NULL) + continue; + $type = gettype($value); + switch ($key) { + case 'dn': { + if ($type != 'string') + return 1; + $this->dn = $value; + } break; + case 'username': { + if ($type != 'string') + return 1; + $this->username = $value; + } break; + case 'email': { + if ($type != 'string') + return 1; + $this->email = $value; + } break; + case 'first_name': { + if ($type != 'string') + return 1; + $this->first_name = $value; + } break; + case 'last_name': { + if ($type != 'string') + return 1; + $this->last_name = $value; + } break; + } + } + + return $this->validate(); + } + + /** + * Converts the user into an array + * @return ?array + */ + public function to_array(): ?array { + if ($this->validate()) + return NULL; + $data = array( + 'dn' => $this->dn, + 'username' => $this->username, + 'email' => $this->email + ); + if ($this->first_name) + $data['first_name'] = $this->first_name; + if ($this->last_name) + $data['last_name'] = $this->last_name; + return $data; + } + + /** + * Writes the HTTP headers + */ + public function write_headers(): int { + if ($this->validate()) + return 1; + + $header_username = getenv("HTTP_USERNAME_HEADER"); + $header_email = getenv("HTTP_EMAIL_HEADER"); + $header_first = getenv("HTTP_FIRST_NAME_HEADER"); + $header_last = getenv("HTTP_LAST_NAME_HEADER"); + + header("{$header_username}: {$this->username}"); + header("{$header_email}: {$this->email}"); + if ($this->first_name) + header("{$header_first}: {$this->first_name}"); + if ($this->last_name) + header("{$header_last}: {$this->last_name}"); + + return 0; + } + +} + +class Session { + + public ?User $user; + public ?int $created; + public ?int $expires; + public ?string $token; + + private int $session_lifetime_seconds; + + function __construct() { + $this->session_lifetime_seconds = 60 * 60 * 24 * 3; + } + + /** + * Validates all required fields are set + */ + private function validate(): int { + if ( + $this->user == NULL || + $this->created == NULL || + $this->expires == NULL || + $this->token == NULL + ) { + return 1; + } + if ($this->expires < time()) + return 1; + return 0; + } + + /** + * Loads Data from the array to self + * @param array $data - the data to load + * @return int 0 on success, 1 on error + */ + public function from_array(array $data): int { + $this->user = NULL; + $this->created = NULL; + $this->expires = NULL; + $this->token = NULL; + + foreach ($data as $key => $value) { + if ($value == NULL) + continue; + $type = gettype($value); + switch ($key) { + case 'user': { + $this->user = new User(); + if ($this->user->from_array($value)) + return 1; + } break; + case 'created': { + if ($type != 'integer') + return 1; + $this->created = $value; + } break; + case 'expires': { + if ($type != 'integer') + return 1; + $this->expires = $value; + } break; + case 'token': { + if ($type != 'string') + return 1; + $this->token = $value; + } break; + } + } + + return $this->validate(); + } + + /** + * Renew the expiry clock + */ + public function reset_expiry(): void { + $this->expires = time() + $this->session_lifetime_seconds; + } + + /** + * Converts the session into an array + * @return ?array + */ + public function to_array(): ?array { + if ($this->validate()) + return NULL; + return array( + 'user' => $this->user->to_array(), + 'created' => $this->created, + 'expires' => $this->expires, + 'token' => $this->token + ); + } + + /** + * Writes the HTTP headers + */ + public function write_headers(): int { + if ($this->validate()) + return 1; + if ($this->user->write_headers()) + return 1; + + $cookie_name = getenv("COOKIE_NAME"); + $cookie_options = array ( + 'expires' => $this->expires, + 'path' => '/', + 'domain' => getenv("COOKIE_DOMAIN"), + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Lax' + ); + setcookie( + $cookie_name, + $this->token, + $cookie_options + ); + + return 0; + } + +} diff --git a/src/web/index.php b/src/web/index.php index 8ae7a95..d4271c9 100644 --- a/src/web/index.php +++ b/src/web/index.php @@ -6,52 +6,11 @@ $webroot = dirname(__FILE__); $publicroot = realpath(dirname(__FILE__) . '/../public'); // load stuff +require($webroot . '/helpers/schema.php'); require($webroot . '/helpers/ldap.php'); require($webroot . '/helpers/auth.php'); +require($webroot . '/router.php'); -// start session -function page($file, $data = array()) { - extract($data); - $webroot = $GLOBALS['webroot']; - require($webroot . '/views/header.php'); - require($webroot . "/views/$file.php"); - require($webroot . '/views/footer.php'); -} - -if ($_SERVER['REQUEST_METHOD'] === 'POST') { - parse_str(file_get_contents('php://input'), $post); - $res = ldap_auth($post['username'], $post['password']); - $msg = ''; - $title = ''; - if ($res) { - $msg = 'Authenticated. You can now go back to your content'; - $title = 'Success'; - key_new($post['username']); - } else { - $msg = 'Invalid Credentials'; - $title = 'Error'; - } - page('message', array( - 'title' => $title, - 'msg' => $msg - )); -} else { - if (($user = key_auth())) { - http_response_code(200); - header("X-Webauth-User: $user"); - die(); - } - - $host = $_SERVER['HTTP_HOST']; - $env = getenv("HTTP_HOST"); - if ($_SERVER['REQUEST_URI'] !== '/login') { - // we are being forwarded authed - // redirect - http_response_code(303); - header("Location: http://$env/login"); - } else { - page('login', array( - 'title' => 'Login' - )); - } -} +// do the +$router = new Router(); +$router->handle(); diff --git a/src/web/router.php b/src/web/router.php new file mode 100644 index 0000000..91deaa2 --- /dev/null +++ b/src/web/router.php @@ -0,0 +1,154 @@ +ldap = new LDAPHelper(); + $this->auth = new AuthHelper(); + + $this->domain = getenv("HTTP_HOST"); + } + + /** + * Displays a page to the user + * @param string $file + * @param array $data + */ + private function send_page( + string $file, + array $data = array() + ): void { + extract($data); + $webroot = $GLOBALS['webroot']; + require($webroot . '/views/header.php'); + require($webroot . "/views/$file.php"); + require($webroot . '/views/footer.php'); + } + + /** + * Displays a message to the user (message page) + * @param string $title + * @param string $msg + * @param int $code + */ + private function send_message( + string $title, + string $msg + ): void { + $this->send_page('message', array( + 'title' => $title, + 'msg' => $msg + )); + } + + /** + * Gets the HTTP request information + */ + private function get_req(): array { + return array( + 'path' => $_SERVER['REQUEST_URI'], + 'method' => $_SERVER['REQUEST_METHOD'], + ); + } + + /** + * @param array $fields + */ + private function get_post_info( + string ...$fields + ): ?array { + $values = array(); + + try { + $temp = NULL; + parse_str(file_get_contents('php://input'), $temp); + foreach ($temp as $key => $value) { + $_POST[$key] = $value; + } + } catch (Exception $_e) {} + + foreach ($fields as $key) { + if (!isset($_POST[$key])) + return NULL; + $values[$key] = $_POST[$key]; + } + + return $values; + } + + private function handle_login(): void { + $info = $this->get_post_info('username', 'password'); + if ($info == NULL) { + http_response_code(400); + $this->send_message('Bad Requet', 'Credentials were not supplied'); + return; + } + + $user = $this->ldap->search($info['username']); + if ($user == NULL || !count($user)) { + http_response_code(400); + $this->send_message('Bad Requst', 'User does not exist'); + return; + } + + $user = $user[0]; + + if ($this->ldap->bind( + $user->dn, + $info['password'] + )) { + http_response_code(400); + $this->send_message('Bad Requst', 'Invalid Credentials'); + return; + } + + $session = $this->auth->create_session($user); + + http_response_code(200); + $session->write_headers(); + $this->send_message('Success', 'Authenticated. You can now go back to your content'); + } + + /** + * Handles the HTTP request + * @param array $req + */ + private function handle_req(array $req): void { + if ($req['method'] == 'POST') { + $this->handle_login(); + return; + } + $session = $this->auth->get_session(); + if ($session == NULL) { + // user is NOT authenticated + if ($req['path'] == '/login') { + // user is requesting login page + http_response_code(200); + $this->send_page('login', array( + 'title' => 'Login' + )); + } else { + // user is trying to forward auth + // redirect them to login + http_response_code(303); + header("Location: http://{$this->domain}/login"); + } + } else { + // user is authenticated + $session->reset_expiry(); + $session->write_headers(); + $this->auth->save_session($session); + } + } + + public function handle(): void { + $req = $this->get_req(); + $this->handle_req($req); + } + +}