many changes

This commit is contained in:
Freya Murphy 2024-05-30 13:05:46 -04:00
parent 258dd67615
commit 39bcb09a36
Signed by: freya
GPG key ID: 744AB800E383AE52
10 changed files with 616 additions and 182 deletions

View file

@ -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 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. It uses LDAP for authentication, which can be configured in the `docker.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
```
Once authenticated, sets the X-Webauth-User header, which can be used by applications to see who is logged in. Once authenticated, sets the X-Webauth-User header, which can be used by applications to see who is logged in.

View file

@ -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

View file

@ -15,7 +15,7 @@ services:
build: ./build/php build: ./build/php
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- ./conf/ldap/ldap.env - docker.env
volumes: volumes:
- ./src:/opt/website:ro - ./src:/opt/website:ro
- ./data/session:/var/lib/php/session - ./data/session:/var/lib/php/session

37
docker.env Normal file
View file

@ -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

View file

@ -21,7 +21,7 @@ body {
color: #fff; color: #fff;
font-family: "Open Sans", Helvetica, Arial, sans-serif; font-family: "Open Sans", Helvetica, Arial, sans-serif;
font-weight: 100; font-weight: 100;
font-size: 12px; font-size: 14px;
} }
main { main {

View file

@ -1,85 +1,83 @@
<?php /* Copyright (c) 2024 Freya Murphy */ <?php /* Copyright (c) 2024 Freya Murphy */
$keys = array(); class AuthHelper {
function get_cookie() { private $session_lifetime_seconds;
$cookie_name = 'X-LDAP-Auth-Key';
if(isset($_COOKIE[$cookie_name])) { function __construct() {
return $_COOKIE[$cookie_name]; $this->session_lifetime_seconds = 60 * 60 * 24 * 3;
} else {
return FALSE;
}
} }
function store_cookie($key) { /**
$cookie_name = 'X-LDAP-Auth-Key'; * Generate a random token
$cookie_options = array ( * @param int $length
'expires' => time() + 60*60*24*30, */
'path' => '/', private function gen_token(int $length): string {
'domain' => getenv("COOKIE_DOMAIN"),
'secure' => true,
'httponly' => true,
'samesite' => 'None'
);
setcookie(
$cookie_name,
$key,
$cookie_options
);
}
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]
);
}
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'; $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
$randomString = ''; $random = '';
for ($i = 0; $i < $n; $i++) { for ($i = 0; $i < $length; $i++) {
$index = rand(0, strlen($characters) - 1); $index = rand(0, strlen($characters) - 1);
$randomString .= $characters[$index]; $random .= $characters[$index];
} }
return $randomString; return $random;
} }
function key_auth() { /**
$key = get_cookie(); * Saves a user into the session specified by their auth key
if ($key === FALSE) { * @param Session $session - the session user data
return FALSE; */
} public function save_session(Session $session): void {
$data = load_key($key); $path = "/tmp/{$session->token}";
if ($data === FALSE) { $data = json_encode($session->to_array());
return FALSE; file_put_contents($path, $data, LOCK_EX);
} }
$user = $data['user'];
$time = $data['time']; /**
$now = time(); * Loads the auth session associated with a specific key
if ($time > $now || $now - $time > 60 * 60 * 24) { * @param string $token - the session $key
return FALSE; */
} private function load_session(string $token): ?Session {
store_key($key, $user); try {
return $user; $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);
} }
function key_new($user) {
$key = get_random(128);
store_key($key, $user);
store_cookie($key);
} }

View file

@ -1,41 +1,120 @@
<?php /* Copyright (c) 2024 Freya Murphy */ <?php /* Copyright (c) 2024 Freya Murphy */
function ldap_auth($auth_username, $auth_password) { class LDAPHelper {
$url = getenv("LDAP_URL");
$bind = getenv("LDAP_BIND_DN");
$password = getenv("LDAP_BIND_PASSWORD");
$bound = getenv("LDAP_BASE_DN");
$filter = getenv("LDAP_FILTER");
$uid = getenv("LDAP_UID");
$conn = @ldap_connect($url); private ?\LDAP\Connection $conn;
if (!$conn) { private array $env;
return NULL; private array $matchers;
}
ldap_set_option($conn, LDAP_OPT_PROTOCOL_VERSION, 3);
$bind_conn = @ldap_bind($conn, $bind, $password); private ?string $bound;
if (!$bind_conn) {
return NULL; function __construct() {
$this->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); 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;
}
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<int,mixed> $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();
$info = @ldap_get_entries($conn, $search);
$user = NULL;
for ($i=0; $i<$info['count']; $i++) { for ($i=0; $i<$info['count']; $i++) {
$user = $info[$i]; $user_arr = $info[$i];
if (!array_key_exists($uid, $user)) $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; continue;
if ($user[$uid][0] == $auth_username) }
break; if (count($usernames) && !in_array($user->username, $usernames)) {
continue;
}
$users[] = $user;
} }
if ($user == NULL) { return $users;
return FALSE;
} }
$succ = @ldap_bind($conn, $user['dn'], $auth_password);
return !!$succ;
} }

236
src/web/helpers/schema.php Normal file
View file

@ -0,0 +1,236 @@
<?php /* Copyright (c) 2024 Freya Murphy */
class User {
public ?string $dn;
public ?string $username;
public ?string $email;
public ?string $first_name;
public ?string $last_name;
function __construct() {}
/**
* Validates all required fields are set
*/
private function validate(): int {
return (
$this->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<string,string>
*/
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<string,mixed>
*/
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;
}
}

View file

@ -6,52 +6,11 @@ $webroot = dirname(__FILE__);
$publicroot = realpath(dirname(__FILE__) . '/../public'); $publicroot = realpath(dirname(__FILE__) . '/../public');
// load stuff // load stuff
require($webroot . '/helpers/schema.php');
require($webroot . '/helpers/ldap.php'); require($webroot . '/helpers/ldap.php');
require($webroot . '/helpers/auth.php'); require($webroot . '/helpers/auth.php');
require($webroot . '/router.php');
// start session // do the
function page($file, $data = array()) { $router = new Router();
extract($data); $router->handle();
$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'
));
}
}

154
src/web/router.php Normal file
View file

@ -0,0 +1,154 @@
<?php /* Copyright (c) 2024 Freya Murphy */
class Router {
private $ldap;
private $auth;
private $domain;
function __construct() {
$this->ldap = new LDAPHelper();
$this->auth = new AuthHelper();
$this->domain = getenv("HTTP_HOST");
}
/**
* Displays a page to the user
* @param string $file
* @param array<string,mixed> $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<string> $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<string,string> $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);
}
}