7 Commits
Author SHA1 Message Date
andrew f5a5d63eb7 finished adding rate limiting 2025-12-03 11:05:32 -05:00
andrew f60a81e259 tentatively added rate limiting, block by IP if too many failed login attempts 2025-12-02 22:16:56 -05:00
andrew 3bb2d9cecd new page for client error (too many requests).. work in progress 2025-12-01 22:47:36 -05:00
andrew 5ebfad604f method in snippet is required 2025-12-01 11:51:08 -05:00
andrew e34645eeaa fixed a typo 2025-11-20 11:21:53 -05:00
andrew 1f5e4847ca fix typo 2025-11-19 10:43:10 -05:00
andrew 91bb67da51 example docker compose 2025-11-19 10:42:13 -05:00
14 changed files with 343 additions and 84 deletions
+3
View File
@@ -0,0 +1,3 @@
[Caddyfile]
indent_style = tab
+37
View File
@@ -0,0 +1,37 @@
# required, if missing will generate random values
# encryption key used to store sessions (static random bytes) in base64
PREAUTH_KEY=''
# TOTP (RFC 6238) secret/token (static random bytes) in base32
PREAUTH_TOKEN=''
# optional, change time-to-live, subdomain, default-redirect, text or colors
# how long a session lasts (in minutes): 43200 is 30 days
PREAUTH_TTL=43200
PREAUTH_SUBDOMAIN='preauth'
PREAUTH_SEND_TO='https://secure.example.com/'
PREAUTH_BACKGROUND='#029386'
PREAUTH_FOREGROUND='#ffffff'
PREAUTH_TITLE='Pre-Authentication System'
PREAUTH_ID_NAME='Session ID'
PREAUTH_TOKEN_NAME='Authentication Token'
PREAUTH_SUBMIT_NAME='Submit'
# how many consecutive failed login attempts before we block them (a remote-ip)
PREAUTH_RATE_LIMIT=4
# maximum time between failed login attempts to still be consecutive (in minutes): 360 is 6 hours
PREAUTH_RATE_TIMEOUT=360
# how long after last failed login will they be blocked (in minutes): 1440 is 24 hours
PREAUTH_RATE_BLOCKED=1440
# what do we show when they get rate-limited
PREAUTH_DENIED_CODE=418
PREAUTH_DENIED_TITLE="I'm a teapot"
PREAUTH_DENIED_MESSAGE='I refuse to brew coffee.'
# alternatively, you could use a more standard response
#PREAUTH_DENIED_CODE=429
#PREAUTH_DENIED_TITLE='Too Many Requests'
#PREAUTH_DENIED_MESSAGE='Try again later.'
# who owns the session files
# permissions of the volume must match
USER_ID=1000
GROUP_ID=1000
+55
View File
@@ -0,0 +1,55 @@
<?php
use Preauth\Env;
global $auth;
$env = new Env();
header("http/1.1 {$env->getDeniedCode()} {$env->getDeniedTitle()}", true, $env->getDeniedCode());
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title><?php echo $env->getTitle(); ?></title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Begin Icons -->
<link rel="apple-touch-icon" sizes="180x180" href="https://<?php echo $auth->getBaseDomain(); ?>/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="https://<?php echo $auth->getBaseDomain(); ?>/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="192x192" href="https://<?php echo $auth->getBaseDomain(); ?>/android-chrome-192x192.png">
<link rel="icon" type="image/png" sizes="16x16" href="https://<?php echo $auth->getBaseDomain(); ?>/favicon-16x16.png">
<link rel="manifest" href="https://<?php echo $auth->getBaseDomain(); ?>/site.webmanifest">
<meta name="apple-mobile-web-app-title" content="<?php echo $env->getTitle(); ?>">
<meta name="application-name" content="<?php echo $env->getTitle(); ?>">
<meta name="msapplication-TileColor" content="<?php echo $env->getColor(); ?>">
<meta name="theme-color" content="<?php echo $env->getColor(); ?>">
<!-- End Icons -->
<style>
* {
margin: 0;
padding: 0.25em;
}
html {
background-color: <?php echo $env->getColor(); ?>;
color: <?php echo $env->getTextColor(); ?>;
display: table;
font-family: sans-serif;
font-size: 1.5em;
height: 100%;
padding: 0;
text-align: center;
width: 100%;
}
body {
display: table-cell;
vertical-align: middle;
}
h1 {
font-size: 2.5em;
font-weight: normal;
}
</style>
</head>
<body>
<h1><?php echo $env->getTitle(); ?></h1>
<h2><?php echo $env->getDeniedTitle(); ?></h2>
<p><?php echo $env->getDeniedMessage(); ?></p>
</body>
</html>
+3 -23
View File
@@ -15,6 +15,7 @@ preauth.example.com {
# snippet to put the pre-auth system in front any service easily
(preauth) {
reverse_proxy {args[0]} preauth:9000 {
method GET
header_up X-Forwarded-Uri {uri}
header_down -X-Powered-By
rewrite /preauth.php
@@ -32,7 +33,8 @@ preauth.example.com {
}
}
# snippet usage: this will cause pre-auth to restrict access to https://protected.example.com/secure
# snippet usage: this will cause preauth to restrict access to
# https://protected.example.com/secure
protected.example.com {
import preauth /secure {
reverse_proxy protected-service:9000
@@ -40,25 +42,3 @@ protected.example.com {
reverse_proxy exposed-service:9000
}
## alternatively, if using an older version of caddy,
## you will have to have add the boilerplate in your block
#protected.example.com {
# reverse_proxy /secure preauth:9000 {
# header_up X-Forwarded-Uri {uri}
# header_down -X-Powered-By
# rewrite /preauth.php
# transport fastcgi {
# root /preauth/
# split .php
# }
# @preauth_ok status 2xx
# handle_response @preauth_ok {
# copy_response_headers {
# include Set-Cookie Location
# }
# reverse_proxy protected-service:9000
# }
# }
# reverse_proxy exposed-service:9000
#}
+11 -4
View File
@@ -8,8 +8,15 @@ RUN mkdir /preauth
WORKDIR /preauth
COPY . /preauth/
# sessions are stored here
RUN mkdir -p /tmp/sessions
# add php config for rate limit monitoring
RUN mkdir -p /usr/local/etc/php/conf.d
COPY preauth-php.ini /usr/local/etc/php/conf.d/preauth-php.ini
# login sessions are stored here
RUN mkdir -p /tmp/data/sessions
# rate limit monitoring information is stored here
RUN mkdir -p /tmp/data/monitor
# fcgi command for the healthcheck
RUN apk add fcgi
@@ -19,9 +26,9 @@ RUN composer install
EXPOSE 9000
HEALTHCHECK --interval=60s --retries=3 --start-interval=1s --start-period=10s --timeout=5s \
HEALTHCHECK --interval=5m --retries=3 --start-interval=5s --start-period=50s --timeout=5s \
CMD SCRIPT_NAME=/health.php SCRIPT_FILENAME=/preauth/health.php REQUEST_METHOD=GET \
cgi-fcgi -bind -connect localhost:9000 | grep 'online' || exit 1
ENTRYPOINT /preauth/init.sh
ENTRYPOINT ["/preauth/init.sh"]
+19
View File
@@ -0,0 +1,19 @@
services:
preauth:
env_file:
# TODO rename ".env.example" to just ".env"
# edit USER_ID and GROUP_ID if needed
# set PREAUTH_KEY and PREAUTH_TOKEN after initial start to persist those settings
- .env
expose:
- 9000
image: digitaladapt/preauth
init: true
restart: unless-stopped
user: ${USER_ID}:${GROUP_ID}
volumes:
- preauth:/tmp/data
volumes:
preauth:
-18
View File
@@ -1,18 +0,0 @@
# required, if missing will generate random values
# encryption key used to store sessions (static random bytes) in base64
PREAUTH_KEY=''
# TOTP (RFC 6238) secret/token (static random bytes) in base32
PREAUTH_TOKEN=''
# optional, change time-to-live, subdomain, default-redirect, text or colors
# how long a session lasts (in minutes): 43200 is 30 days
PREAUTH_TTL=43200
PREAUTH_SUBDOMAIN='preauth'
PREAUTH_SEND_TO='https://secure.example.com/'
PREAUTH_BACKGROUND='#029386'
PREAUTH_FOREGROUND='#ffffff'
PREAUTH_TITLE='Pre-Authentication System'
PREAUTH_ID_NAME='Session ID'
PREAUTH_TOKEN_NAME='Authentication Token'
PREAUTH_SUBMIT_NAME='Submit'
+11 -2
View File
@@ -1,4 +1,13 @@
<?php
/* ensure php is processing */
echo implode('', ['o', 'n', 'l', 'i', 'n', 'e', "\n"]);
/* do garbage collection on rate limit monitoring sessions */
session_start();
$success = session_gc();
session_destroy();
if ($success !== false) {
/* if garbage collection was successful, report that we are healthy */
echo implode('', ['o', 'n', 'l', 'i', 'n', 'e', "\n"]);
} else {
echo "error\n";
}
+5
View File
@@ -1,5 +1,9 @@
#!/bin/sh
# ensure we have required folders
`mkdir -p /tmp/data/sessions`
`mkdir -p /tmp/data/monitor`
# ensure we have required settings
if [ -z "$PREAUTH_KEY" ]; then
export PREAUTH_KEY=$(cd /preauth && php init-key.php)
@@ -9,5 +13,6 @@ if [ -z "$PREAUTH_TOKEN" ]; then
export PREAUTH_TOKEN=$(cd /preauth && php init-token.php)
fi
# start the process, now that setup is done
exec php-fpm
+21
View File
@@ -0,0 +1,21 @@
; preauth uses php sessions to store usage statistics by IP address and is used for rate limiting
; login sessions are stored as file in /tmp/data/sessions, and are unrelated to this configuration
; sessions are IP based, cookie not needed nor wanted
session.use_cookies = off
; deprecated in php 8.4
;session.use_only_cookies = off
session.cache_limiter = ''
session.name = preauth
session.save_path = /tmp/data/monitor
; seconds until a session may be pruned
; IE: maximum time range to review for rate limiting and
; maximum time an IP address can be blocked
; 86400 seconds aka 24 hours hardcoded upper limit
session.gc_maxlifetime = 86400
; disable random random garbage collection
; our healthcheck runs session_gc function
session.gc_probability = 0
+2 -2
View File
@@ -10,7 +10,7 @@ Maybe you need the extra protection because it's a very sensitive system, or bec
* Docker
* Caddy (as a reverse proxy)
* a domain
* a web service you want to secure
* a web service you want to secure
It may be possible to use some other reverse proxy, but for now, I'm going to stick with just Caddy.
@@ -24,4 +24,4 @@ I say login, but it's really just a TOTP code (6 digit code which changes every
First time you spin up the docker container it will generate an encryption key for session storage, and the TOTP secret (which you'll load into your authenticator app).
Be sure to save those and add them to the containers environment, or it will generate new values every time it restarts.
Be sure to save those and add them to the containers environment, or it will generate new values every time it restarts.
+141 -28
View File
@@ -8,6 +8,12 @@ use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\Uid\Uuid;
class Auth {
/* name of the return-to field */
public const RETURN_FIELD = 'preauth_rt';
/* name of the id field */
public const ID_FIELD = 'preauth_id';
/* name of the token field */
public const TOKEN_FIELD = 'preauth_token';
/* the encryption cipher we are using */
private const CIPHER = 'camellia-256-ctr';
/* only allow A-z 0-9 _ - */
@@ -15,7 +21,7 @@ class Auth {
/* cookie name */
private const NAME = '_auth_uuid';
/* directory to store sessions in */
private const BASE = '/tmp/sessions/';
private const BASE = '/tmp/data/sessions/';
/* top-level-domains which are known to have multiple parts */
private const TLD = [
'ai' => ['com','net','off','org'],
@@ -61,7 +67,9 @@ class Auth {
$this->key = base64_decode(getenv('PREAUTH_KEY') ?: '');
$this->token = getenv('PREAUTH_TOKEN') ?: '';
$this->expire = time() + 60 * ((int)getenv('PREAUTH_TTL') ?: 43200);
parse_str((parse_url(($_SERVER['HTTP_X_FORWARDED_URI'] ?? ''), PHP_URL_QUERY) ?: ''), $this->get);
parse_str((parse_url((
$_SERVER['HTTP_X_FORWARDED_URI'] ?? ''
), PHP_URL_QUERY) ?: ''), $this->get);
}
/**
@@ -75,7 +83,7 @@ class Auth {
* @return string returns the return-to-url, if one was specified, empty string otherwise
*/
public function getReturnTo(): string {
$rt = $this->get['rt'] ?? '';
$rt = $this->get[self::RETURN_FIELD] ?? '';
if ( ! is_string($rt)) {
$rt = '';
}
@@ -103,6 +111,13 @@ class Auth {
echo "ok $this->id";
return;
}
/* if too many requests (from the remote-ip), then trigger rate limiting */
if ($this->rateLimit()) {
include __DIR__ . '/../400.php';
exit(0);
}
/* if request is valid login attempt, (set cookie and) return to where they came from */
if ($this->login()) {
if ($this->getReturnTo()) {
@@ -114,18 +129,20 @@ class Auth {
return;
}
/* not already logged in, not trying to login, but on auth page, so present login screen */
/* not already logged in, but on auth page, so present login screen */
/* either not trying to login or had a failed login attempt */
if ($_SERVER['HTTP_HOST'] === "$this->preauth.$this->domain") {
header('http/1.1 401 Unauthorized', true, 401);
$this->stop = false;
} else {
/* not already logged in, not trying to login, and not on auth page, so send to login screen */
$rt = rawurlencode(
/* not already logged in, not trying to login, and not on auth page */
/* so send to login screen */
$query = self::RETURN_FIELD . '=' . rawurlencode(
($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? 'https') . '://' .
($_SERVER['HTTP_X_FORWARDED_HOST'] ?? $this->domain) .
($_SERVER['HTTP_X_FORWARDED_URI'] ?? '/')
);
header("Location: https://$this->preauth.$this->domain/?rt=$rt");
header("Location: https://$this->preauth.$this->domain/?$query");
}
}
@@ -139,7 +156,9 @@ class Auth {
$parts = array_reverse(explode('.', $host));
$keep = min(2, count($parts));
/* check if host should retain 3 parts, due to TLD */
if (count($parts) > 2 && isset(self::TLD[$parts[0]]) && in_array($parts[1], self::TLD[$parts[0]], true)) {
if (count($parts) > 2 && isset(self::TLD[$parts[0]]) &&
in_array($parts[1], self::TLD[$parts[0]], true)
) {
$keep = 3;
}
$parts = array_reverse(array_slice($parts, 0, $keep));
@@ -157,15 +176,18 @@ class Auth {
}
/**
* @return string|null returns the existing UUID if there is one and it is valid, null otherwise
* @return string|null returns the existing UUID if there is valid one, null otherwise
*/
private function getExistingUUID(): ?string {
/* filter user input */
$encUUID = preg_replace(self::URL64, '', ($_COOKIE[self::NAME] ?? ''));
/* session exists as a file, in the format '<iv-b64>$<session-name>', where the filename is the encoded-uuid */
/* session exists as a file where the filename is the encoded-uuid */
/* in the format '<iv-b64>$<session-name>$<expiration>$<date>$<remote-host>' */
if ($encUUID && is_file(self::BASE . $encUUID)) {
[$iv, $id, $expire] = explode('$', (file_get_contents(self::BASE . $encUUID) ?: '') . '$$');
[$iv, $id, $expire] = explode('$', (file_get_contents(
self::BASE . $encUUID
) ?: '') . '$$');
if ($iv) {
/* raw-uuid means not encoded, but still encrypted */
$rawUUID = base64_decode(strtr($encUUID, '+/', '-_'));
@@ -175,13 +197,56 @@ class Auth {
$this->id = substr(preg_replace(self::URL64, '', $id), 0, 100);
return $uuid;
}
/* we have something encrypted, but it is expired or not valid, so delete the session file */
/* we have something encrypted, but it is expired or invalid, so delete it */
unlink(self::BASE . $encUUID);
}
}
return null;
}
/**
* Check if the remote-host has made too many login requests recently, and block if needed
* @return bool returns true if we should rate-limit this request, false otherwise
*/
private function rateLimit(): bool {
/* our identifier for this remote-host, replace all special characters with dashes */
/* session_id() only allows ",", "-", and alphanumeric characters */
$limiterId = preg_replace('/[^a-zA-Z0-9]/', '-', $this->getRemoteHost());
//error_log("limiterId: {$limiterId}");
session_id($limiterId);
session_start();
/* new remote-ip, start logging */
if ( ! isset($_SESSION['count'], $_SESSION['time'])) {
$this->resetRateLimit(false);
//error_log('limiter new, allow');
return false;
}
$sessionCount = (int)$_SESSION['count'];
$sessionTime = (int)$_SESSION['time'];
$rateLimit = (int)getenv('PREAUTH_RATE_LIMIT') ?: 4;
$rateTimeout = (int)getenv('PREAUTH_RATE_TIMEOUT') ?: 360;
$rateBlocked = (int)getenv('PREAUTH_RATE_BLOCKED') ?: 1440;
$rateMaximum = max($rateTimeout, $rateBlocked);
if ($sessionCount >= $rateLimit && time() - $rateBlocked <= $sessionTime) {
/* if over limit, and block current, block them */
//error_log("limiter over limit, block ($sessionCount)");
return true;
} else if ($sessionCount < $rateLimit && time() - $rateTimeout <= $sessionTime) {
/* if under limit, and timeout current, allow them */
//error_log("limiter under limit, allow ($sessionCount)");
return false;
}
/* either over limit and block has expired or */
/* under limit and timeout has expired, so reset them */
//error_log("limiter expired, reset");
$this->resetRateLimit(false);
return false;
}
/**
* @return string returns a newly generated random uuid
*/
@@ -198,30 +263,27 @@ class Auth {
*/
private function login(): bool {
/* fields must both be filled out */
if (isset($this->get['token'], $this->get['id']) && $this->get['token'] && $this->get['id']) {
if (isset($this->get[self::TOKEN_FIELD], $this->get[self::ID_FIELD]) &&
$this->get[self::TOKEN_FIELD] && $this->get[self::ID_FIELD]
) {
/* filter user input */
$id = substr(preg_replace(self::URL64, '', $this->get['id']), 0, 100);
$id = substr(preg_replace(self::URL64, '', $this->get[self::ID_FIELD]), 0, 100);
$otp = TOTP::createFromSecret($this->token);
$date = date('Y-m-d H:i:s');
/* determine real remote-host, if local address, find next level up */
$remoteHost = $_SERVER['REMOTE_HOST'];
if (IpUtils::isPrivateIp($remoteHost)) {
$remoteList = array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''));
$remoteIndex = array_search($remoteHost, $remoteList);
if ($remoteIndex > 0) {
$remoteHost = $remoteList[$remoteIndex - 1];
}
}
$remoteHost = $this->getRemoteHost();
/* if given token is valid, login, store session file and set the cookie */
if ($otp->now() === $this->get['token']) {
if ($otp->now() === $this->get[self::TOKEN_FIELD]) {
$this->id = $id;
$uuid = $this->newUUID();
$rawIV = random_bytes(openssl_cipher_iv_length(self::CIPHER));
$rawUUID = openssl_encrypt($uuid, self::CIPHER, $this->key, 0, $rawIV);
$encUUID = strtr(base64_encode($rawUUID), '-_', '+/');
file_put_contents(self::BASE . $encUUID, base64_encode($rawIV) . "\$$id\$$this->expire\$$date\$$remoteHost\$\n");
file_put_contents(
self::BASE . $encUUID,
base64_encode($rawIV) .
"\$$id\$$this->expire\$$date\$$remoteHost\$\n"
);
setcookie(
self::NAME,
$encUUID,
@@ -232,12 +294,63 @@ class Auth {
true /* no js access */
);
error_log("[$date] successful login by id: $id");
/* successful login, reset the rate-limit and close */
$this->resetRateLimit();
return true;
}
/* log failed logins, so we can fail2ban bad actors */
error_log("[$date] failed login attempted by ip: $remoteHost");
/* login attempted and failed, update monitoring for rate limiting */
$sessionCount = (int)$_SESSION['count'];
$rateLimit = (int)getenv('PREAUTH_RATE_LIMIT') ?: 4;
$this->logFailedAttempt();
if (($sessionCount + 1) >= $rateLimit) {
/* +1 to count this failure */
error_log("[$date] rate-limiting trigger for: $remoteHost");
/* just reached the rate limit, block them */
include __DIR__ . '/../400.php';
exit(0);
}
}
return false;
}
/**
* Determine real remote-host, if local address, find next level up
* @return string returns the real remote host (IP address)
*/
private function getRemoteHost(): string {
$remoteHost = $_SERVER['REMOTE_HOST'];
if (IpUtils::isPrivateIp($remoteHost)) {
$remoteList = array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''));
$remoteIndex = array_search($remoteHost, $remoteList);
if ($remoteIndex > 0) {
$remoteHost = $remoteList[$remoteIndex - 1];
}
}
return $remoteHost;
}
/**
* [Re]Initialize the rate-limiting and close monitoring session (unless you pass false)
* @param bool $close Defaults to true, set to false to keep monitoring session open
*/
private function resetRateLimit(bool $close = true): void {
$_SESSION['time'] = time();
$_SESSION['count'] = 0;
if ($close) {
session_write_close();
}
}
/**
* Note that login failed so we can determine if we should rate-limit future requests
*/
private function logFailedAttempt(): void {
$_SESSION['time'] = time();
$_SESSION['count']++;
session_write_close();
}
}
+31 -4
View File
@@ -8,21 +8,24 @@ class Env {
* @return string returns title of this system
*/
public function getTitle(): string {
return getenv('PREAUTH_TITLE') ?: 'Pre-Authentication System';
return getenv('PREAUTH_TITLE')
?: 'Pre-Authentication System';
}
/**
* @return string returns background-color of this system
*/
public function getColor(): string {
return getenv('PREAUTH_BACKGROUND') ?: '#029386'; // teal
// defaults to teal
return getenv('PREAUTH_BACKGROUND') ?: '#029386';
}
/**
* @return string returns text-color of this system
*/
public function getTextColor(): string {
return getenv('PREAUTH_FOREGROUND') ?: '#ffffff'; // white
// defaults to white
return getenv('PREAUTH_FOREGROUND') ?: '#ffffff';
}
/**
@@ -36,7 +39,8 @@ class Env {
* @return string returns the name of the Token field
*/
public function getTokenName(): string {
return getenv('PREAUTH_TOKEN_NAME') ?: 'Authentication Token';
return getenv('PREAUTH_TOKEN_NAME')
?: 'Authentication Token';
}
/**
@@ -45,5 +49,28 @@ class Env {
public function getSubmitName(): string {
return getenv('PREAUTH_SUBMIT_NAME') ?: 'Submit';
}
/**
* @return string returns the denied http status code
*/
public function getDeniedCode(): string {
return getenv('PREAUTH_DENIED_CODE') ?: '418';
}
/**
* @return string returns the denied response title
*/
public function getDeniedTitle(): string {
return getenv('PREAUTH_DENIED_TITLE')
?: "I'm a teapot";
}
/**
* @return string returns the denied response message
*/
public function getDeniedMessage(): string {
return getenv('PREAUTH_DENIED_MESSAGE')
?: 'I refuse to brew coffee.';
}
}
+4 -3
View File
@@ -1,4 +1,5 @@
<?php
use Preauth\Auth;
use Preauth\Env;
global $auth;
$env = new Env();
@@ -63,11 +64,11 @@ $env = new Env();
<body>
<h1><?php echo $env->getTitle(); ?></h1>
<form action="/" method="get">
<input type="hidden" name="rt" value="<?php echo $auth->getReturnTo(); ?>">
<input type="hidden" name="<?php echo Auth::RETURN_FIELD; ?>" value="<?php echo $auth->getReturnTo(); ?>">
<div class="right"><label for="id"><?php echo $env->getIdName(); ?>:</label></div>
<div><input type="text" name="id" id="id" autocomplete="on" required="required" autofocus="autofocus"></div>
<div><input type="text" name="<?php echo Auth::ID_FIELD; ?>" id="id" autocomplete="on" required="required" autofocus="autofocus"></div>
<div class="right"><label for="token"><?php echo $env->getTokenName(); ?>:</label></div>
<div><input type="text" name="token" id="token" autocomplete="off" required="required"></div>
<div><input type="text" name="<?php echo Auth::TOKEN_FIELD; ?>" id="token" autocomplete="off" required="required"></div>
<div class="center"><button type="submit"><?php echo $env->getSubmitName(); ?></button></div>
</form>
</body>