diff --git a/composer.json b/composer.json index 674123b..f147509 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,8 @@ "ext-pdo": "^7.3", "ext-json": "*", "ext-yaml": "^2.0", - "league/mime-type-detection": "^1.11" + "league/mime-type-detection": "^1.11", + "apereo/phpcas": "^1.6" }, "require-dev": { "phpstan/phpstan": "^1.9" diff --git a/example/includes/config.yml b/example/includes/config.yml index 1f1c5b3..1b2794f 100644 --- a/example/includes/config.yml +++ b/example/includes/config.yml @@ -134,6 +134,140 @@ db: #datetime_format: '%Y-%m-%d %H:%M:%S' # Exemple : 2018-10-12 18:06:59 +# +# Authentication +# +auth: + # Enabled authentication + enabled: false + + # Methods to authenticate users + methods: + - form + - http + #- cas + + # User backends + backends: + - ldap + + # + # Login form + # + login_form: + # Display link for other authentication methods + # Note: method as key and label as value + display_other_methods: + http: "HTTP" + cas: "SSO" + + # + # HTTP Authentication Configuration + # + http: + # HTTP Auth methods : + # * AUTHORIZATION : use HTTP_AUTHORIZATION environnement. This mode could be use with PHP FPM. + # Specific configuration is need in Apache to use this mode : + # RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + # * REMOTE_USER : use REMOTE_USER environnement variable to retreive authenticated user's login + # This method could be only used with $http_auth_trust_without_password_challenge + # enabled. + # * PHP_AUTH : use PHP_AUTH_USER and PHP_AUTH_PW environnement variables (only available + # using mod_php with Apache. (default) + method: 'PHP_AUTH' + + # Trust HTTP server authentication + # If enabled, the application will no check user credentials and only retreive user's login + # from HTTP server. + #trust_without_password_challenge: true + + # Realm (use when force HTTP login, optional) + #realm: "Authentication required" + + # + # CAS Configuration + # + cas: + # CAS host (just the domain name) + host: 'idp.example.com' + + # CAS context (the root path, default: '/idp/cas') + # Example: for 'http://idp.example.com/idp/cas', put '/idp/cas' + context: '/idp/cas' + + # CAS HTTP port (default: 443) + #port: 443 + + # CAS procotol version + # Posssible values: "1.0", "2.0" (default), "3.0" or "S1" (SAML1) + #version: '2.0' + + # CAS server SSL certificate validation (set to false to disable) + ca_cert_certificate_path: "/etc/ssl/certs/ca-certificates.crt" + + # CAS Debug log file + #debug_log_file: "${root_directory_path}/data/logs/cas.log" + + # CAS Logout + #logout: true # Enable CAS logout on app logout + #logout_url: "https://my.example.fr/logout/" # Specify custom CAS logout URL + + # CAS Fake authenticated user + #fake_authenticated_user: 'myusername' + + # + # LDAP user backend + # + ldap: + # LDAP host (required, multiple hosts could be specified with comma separator) + host: 'ldap://localhost' + + # LDAP port (optional) + #port: 389 + + # Enable STARTTLS (optional, default: false) + #starttls: false + + # LDAP directory base DN (required) + basedn: 'o=example' + + # LDAP bind DN (optional) + #bind_dn: 'uid=eesyphp,ou=sysaccounts,${auth.ldap.basedn}' + + # LDAP bind password (optional) + #bind_password: 'secret' + + # User search filter by username. The keyword "[username]" will be replace before search by + # the looked username (default: "uid=[username]") + #user_filter_by_uid: 'uid=[username]' + + # User base DN + user_basedn: 'ou=people,${auth.ldap.basedn}' + + # Bind with username instead of user DN (optional, default: false) + #bind_with_username: true + + # LDAP user attributes to retreive with their properties: + # [LDAP attr name]: + # name: [map name] # optional, default: LDAP attr name + # type: [type of value] # optional, default: 'string', possible values: string, bool, int, float + # multivalued: true # optional, default: false + # default: null # optional, default: null + user_attributes: + uid: + name: 'login' + multivalued: false + default: null + cn: + name: 'name' + multivalued: false + default: null + mail: + type: 'string' + + # PEAR Net_LDAP2 library path (optional, default: Net/LDAP2.php) + #netldap2_path: 'Net/LDAP2.php' + # # Email configuration # diff --git a/locales/fr_FR.UTF8/LC_MESSAGES/EESYPHP.mo b/locales/fr_FR.UTF8/LC_MESSAGES/EESYPHP.mo index 94e8e7a..000bbdf 100644 Binary files a/locales/fr_FR.UTF8/LC_MESSAGES/EESYPHP.mo and b/locales/fr_FR.UTF8/LC_MESSAGES/EESYPHP.mo differ diff --git a/locales/fr_FR.UTF8/LC_MESSAGES/EESYPHP.po b/locales/fr_FR.UTF8/LC_MESSAGES/EESYPHP.po index d15d8bd..d93a61b 100644 --- a/locales/fr_FR.UTF8/LC_MESSAGES/EESYPHP.po +++ b/locales/fr_FR.UTF8/LC_MESSAGES/EESYPHP.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"POT-Creation-Date: 2023-02-14 01:17+0100\n" +"POT-Creation-Date: 2023-02-25 04:54+0100\n" "PO-Revision-Date: \n" "Last-Translator: Benjamin Renard \n" "Language-Team: \n" @@ -70,6 +70,22 @@ msgstr "Une exception est survenue en exécutant la commande %s" msgid "Unable to connect to the database." msgstr "Impossible de se connecter à la base de données." +#: Auth/Form.php:32 +msgid "Invalid username or password." +msgstr "Nom d'utilisateur ou mot de passe invalide." + +#: Auth/Http.php:34 Url.php:176 +msgid "Authentication required" +msgstr "Authentification requise" + +#: Auth/Http.php:115 Auth/Http.php:118 Url.php:180 +msgid "Access denied" +msgstr "Accès interdit" + +#: Auth/Http.php:119 Auth/Http.php:123 +msgid "You must login to access this page." +msgstr "Vous devez vous connecter pour accéder à cette page." + #: Email.php:141 #, php-format msgid "

Mail initialy intended for %s.

" @@ -102,35 +118,27 @@ msgstr "" "\n" "%s: %s" -#: Tpl.php:355 +#: Tpl.php:394 msgid "No template specified." msgstr "Aucun template spécifié." -#: Tpl.php:386 +#: Tpl.php:425 msgid "An error occurred while displaying this page." msgstr "Une erreur est survenue en affichant cette page." -#: Url.php:141 +#: Url.php:172 msgid "Bad request" msgstr "Mauvaise requête" -#: Url.php:142 +#: Url.php:173 msgid "Invalid request." msgstr "Requête invalide." -#: Url.php:145 -msgid "Authentication required" -msgstr "Authentification requise" - -#: Url.php:146 +#: Url.php:177 msgid "You have to be authenticated to access to this page." msgstr "Vous devez être authentifié pour accéder à cette page." -#: Url.php:149 -msgid "Access denied" -msgstr "Accès interdit" - -#: Url.php:150 +#: Url.php:181 msgid "" "You do not have access to this application. If you think this is an error, " "please contact support." @@ -138,25 +146,25 @@ msgstr "" "Vous n'avez pas accès à cette application. Si vous pensez qu'il s'agit d'une " "erreur, merci de prendre contact avec le support." -#: Url.php:153 +#: Url.php:184 msgid "Whoops ! Page not found" msgstr "Oups ! Page introuvable" -#: Url.php:154 +#: Url.php:185 msgid "The requested page can not be found." msgstr "La page demandée est introuvable." -#: Url.php:162 +#: Url.php:193 msgid "Error" msgstr "Erreur" -#: Url.php:163 +#: Url.php:194 msgid "An unknown error occurred. If problem persist, please contact support." msgstr "" "Une erreur inconnue est survenue. Si le problème persiste, merci de prendre " "contact avec le support." -#: Url.php:226 +#: Url.php:257 msgid "" "Unable to determine the requested page. If the problem persists, please " "contact support." @@ -164,7 +172,7 @@ msgstr "" "Impossible de déterminer la page demandée. Si le problème persiste, merci de " "prendre contact avec le support." -#: Url.php:376 +#: Url.php:411 msgid "" "Unable to determine the requested page (loop detected). If the problem " "persists, please contact support." @@ -172,22 +180,19 @@ msgstr "" "Impossible de déterminer la page demandée (boucle détectée). Si le problème " "persiste, merci de prendre contact avec le support." -#: Url.php:407 +#: Url.php:441 msgid "This request cannot be processed." msgstr "Cette requête ne peut être traitée." -#: Url.php:420 -msgid "" -"Authentication required but force_authentication function is not defined." -msgstr "" -"Authentification requise mais la fonction force_authentication n'est pas " -"définie." +#: Url.php:451 +msgid "Authentication required but fail to authenticate you." +msgstr "Authentification requise mais impossible pour vous authentifier." -#: Url.php:429 +#: Url.php:460 msgid "This request could not be processed correctly." msgstr "Cette requête n'a put être traitée correctement." -#: I18n.php:122 App.php:120 +#: I18n.php:122 App.php:124 msgid "Hello world !" msgstr "Bonjour tout le monde !" @@ -379,10 +384,18 @@ msgstr "Impossible d'écrire le fichier du catalogue JS %s (%s)." msgid "%s JS catalog writed (%s)." msgstr "Catalogue JS %s créé (%s)." -#: App.php:122 +#: App.php:126 msgid "Hello world!" msgstr "Salut tout le monde !" +#: App.php:137 +msgid "Disconnected" +msgstr "Déconnecté" + +#: App.php:139 +msgid "You are now disconnected." +msgstr "Vous êtes maintenant déconnecté." + #: static/js/myconfirm.js:4 static/js/myconfirm.js:171 #: static/js/myconfirm.js:200 msgid "Confirmation" @@ -460,3 +473,29 @@ msgstr "" "framework EesyPHP. Configurez votre propre dossier de templates et créer le " "fichier homepage.tpl pour l'écraser. Vous pouvez également écraser " "le gestionnaire de l'URL racine de l'application web." + +#: templates/empty.tpl:60 +msgid "Logout" +msgstr "Déconnexion" + +#: templates/login.tpl:2 +msgid "Connection" +msgstr "Connexion" + +#: templates/login.tpl:7 +msgid "Username" +msgstr "Nom d'utilisateur" + +#: templates/login.tpl:11 +msgid "Password" +msgstr "Mot de passe" + +#: templates/login.tpl:14 +msgid "Submit" +msgstr "Envoyer" + +#~ msgid "" +#~ "Authentication required but force_authentication function is not defined." +#~ msgstr "" +#~ "Authentification requise mais la fonction force_authentication n'est pas " +#~ "définie." diff --git a/locales/headers.pot b/locales/headers.pot index 7d2ed67..93eecfd 100644 --- a/locales/headers.pot +++ b/locales/headers.pot @@ -1,7 +1,7 @@ msgid "" msgstr "" -"POT-Creation-Date: 2023-02-14 01:17+0100\n" -"PO-Revision-Date: 2023-02-14 01:17+0100\n" +"POT-Creation-Date: 2023-02-25 04:54+0100\n" +"PO-Revision-Date: 2023-02-25 04:54+0100\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" \ No newline at end of file diff --git a/locales/messages.pot b/locales/messages.pot index c3a2ba7..10c1bd5 100644 --- a/locales/messages.pot +++ b/locales/messages.pot @@ -1,7 +1,7 @@ msgid "" msgstr "" -"POT-Creation-Date: 2023-02-14 01:17+0100\n" -"PO-Revision-Date: 2023-02-14 01:17+0100\n" +"POT-Creation-Date: 2023-02-25 04:54+0100\n" +"PO-Revision-Date: 2023-02-25 04:54+0100\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" @@ -61,6 +61,22 @@ msgstr "" msgid "Unable to connect to the database." msgstr "" +#: Auth/Form.php:32 +msgid "Invalid username or password." +msgstr "" + +#: Auth/Http.php:34 Url.php:176 +msgid "Authentication required" +msgstr "" + +#: Auth/Http.php:115 Auth/Http.php:118 Url.php:180 +msgid "Access denied" +msgstr "" + +#: Auth/Http.php:119 Auth/Http.php:123 +msgid "You must login to access this page." +msgstr "" + #: Email.php:141 #, php-format msgid "

Mail initialy intended for %s.

" @@ -87,82 +103,73 @@ msgid "" "%s: %s" msgstr "" -#: Tpl.php:355 +#: Tpl.php:394 msgid "No template specified." msgstr "" -#: Tpl.php:386 +#: Tpl.php:425 msgid "An error occurred while displaying this page." msgstr "" -#: Url.php:141 +#: Url.php:172 msgid "Bad request" msgstr "" -#: Url.php:142 +#: Url.php:173 msgid "Invalid request." msgstr "" -#: Url.php:145 -msgid "Authentication required" -msgstr "" - -#: Url.php:146 +#: Url.php:177 msgid "You have to be authenticated to access to this page." msgstr "" -#: Url.php:149 -msgid "Access denied" -msgstr "" - -#: Url.php:150 +#: Url.php:181 msgid "" "You do not have access to this application. If you think this is an error, " "please contact support." msgstr "" -#: Url.php:153 +#: Url.php:184 msgid "Whoops ! Page not found" msgstr "" -#: Url.php:154 +#: Url.php:185 msgid "The requested page can not be found." msgstr "" -#: Url.php:162 +#: Url.php:193 msgid "Error" msgstr "" -#: Url.php:163 +#: Url.php:194 msgid "An unknown error occurred. If problem persist, please contact support." msgstr "" -#: Url.php:226 +#: Url.php:257 msgid "" "Unable to determine the requested page. If the problem persists, please " "contact support." msgstr "" -#: Url.php:376 +#: Url.php:411 msgid "" "Unable to determine the requested page (loop detected). If the problem " "persists, please contact support." msgstr "" -#: Url.php:407 +#: Url.php:441 msgid "This request cannot be processed." msgstr "" -#: Url.php:420 -msgid "" -"Authentication required but force_authentication function is not defined." +#: Url.php:451 +msgid "Authentication required but fail to authenticate you." msgstr "" -#: Url.php:429 +#: Url.php:460 msgid "This request could not be processed correctly." msgstr "" -#: I18n.php:122 App.php:120 +#: I18n.php:122 App.php:124 msgid "Hello world !" msgstr "" @@ -321,10 +328,18 @@ msgstr "" msgid "%s JS catalog writed (%s)." msgstr "" -#: App.php:122 +#: App.php:126 msgid "Hello world!" msgstr "" +#: App.php:137 +msgid "Disconnected" +msgstr "" + +#: App.php:139 +msgid "You are now disconnected." +msgstr "" + #: static/js/myconfirm.js:4 static/js/myconfirm.js:171 #: static/js/myconfirm.js:200 msgid "Confirmation" @@ -395,3 +410,23 @@ msgid "" "it. You could also overwrite the URL handler for the root of the web " "application." msgstr "" + +#: templates/empty.tpl:60 +msgid "Logout" +msgstr "" + +#: templates/login.tpl:2 +msgid "Connection" +msgstr "" + +#: templates/login.tpl:7 +msgid "Username" +msgstr "" + +#: templates/login.tpl:11 +msgid "Password" +msgstr "" + +#: templates/login.tpl:14 +msgid "Submit" +msgstr "" diff --git a/locales/php-messages.pot b/locales/php-messages.pot index 3019565..737a968 100644 --- a/locales/php-messages.pot +++ b/locales/php-messages.pot @@ -53,6 +53,22 @@ msgstr "" msgid "Unable to connect to the database." msgstr "" +#: Auth/Form.php:32 +msgid "Invalid username or password." +msgstr "" + +#: Auth/Http.php:34 Url.php:176 +msgid "Authentication required" +msgstr "" + +#: Auth/Http.php:115 Auth/Http.php:118 Url.php:180 +msgid "Access denied" +msgstr "" + +#: Auth/Http.php:119 Auth/Http.php:123 +msgid "You must login to access this page." +msgstr "" + #: Email.php:141 #, php-format msgid "

Mail initialy intended for %s.

" @@ -79,82 +95,73 @@ msgid "" "%s: %s" msgstr "" -#: Tpl.php:355 +#: Tpl.php:394 msgid "No template specified." msgstr "" -#: Tpl.php:386 +#: Tpl.php:425 msgid "An error occurred while displaying this page." msgstr "" -#: Url.php:141 +#: Url.php:172 msgid "Bad request" msgstr "" -#: Url.php:142 +#: Url.php:173 msgid "Invalid request." msgstr "" -#: Url.php:145 -msgid "Authentication required" -msgstr "" - -#: Url.php:146 +#: Url.php:177 msgid "You have to be authenticated to access to this page." msgstr "" -#: Url.php:149 -msgid "Access denied" -msgstr "" - -#: Url.php:150 +#: Url.php:181 msgid "" "You do not have access to this application. If you think this is an error, " "please contact support." msgstr "" -#: Url.php:153 +#: Url.php:184 msgid "Whoops ! Page not found" msgstr "" -#: Url.php:154 +#: Url.php:185 msgid "The requested page can not be found." msgstr "" -#: Url.php:162 +#: Url.php:193 msgid "Error" msgstr "" -#: Url.php:163 +#: Url.php:194 msgid "An unknown error occurred. If problem persist, please contact support." msgstr "" -#: Url.php:226 +#: Url.php:257 msgid "" "Unable to determine the requested page. If the problem persists, please " "contact support." msgstr "" -#: Url.php:376 +#: Url.php:411 msgid "" "Unable to determine the requested page (loop detected). If the problem " "persists, please contact support." msgstr "" -#: Url.php:407 +#: Url.php:441 msgid "This request cannot be processed." msgstr "" -#: Url.php:420 -msgid "" -"Authentication required but force_authentication function is not defined." +#: Url.php:451 +msgid "Authentication required but fail to authenticate you." msgstr "" -#: Url.php:429 +#: Url.php:460 msgid "This request could not be processed correctly." msgstr "" -#: I18n.php:122 App.php:120 +#: I18n.php:122 App.php:124 msgid "Hello world !" msgstr "" @@ -313,6 +320,14 @@ msgstr "" msgid "%s JS catalog writed (%s)." msgstr "" -#: App.php:122 +#: App.php:126 msgid "Hello world!" msgstr "" + +#: App.php:137 +msgid "Disconnected" +msgstr "" + +#: App.php:139 +msgid "You are now disconnected." +msgstr "" diff --git a/locales/templates-0-messages.pot b/locales/templates-0-messages.pot index fd2244d..e239bf1 100644 --- a/locales/templates-0-messages.pot +++ b/locales/templates-0-messages.pot @@ -19,6 +19,14 @@ msgstr "" msgid "Back" msgstr "" +#: templates/logout.tpl:5 +msgid "Disconnected" +msgstr "" + +#: templates/logout.tpl:6 +msgid "You are now disconnected." +msgstr "" + #: templates/homepage.tpl:5 msgid "Hello, world!" msgstr "" @@ -30,3 +38,31 @@ msgid "" "it. You could also overwrite the URL handler for the root of the web " "application." msgstr "" + +#: templates/empty.tpl:60 +msgid "Logout" +msgstr "" + +#: templates/login.tpl:2 +msgid "Connection" +msgstr "" + +#: templates/login.tpl:7 +msgid "Username" +msgstr "" + +#: templates/login.tpl:11 +msgid "Password" +msgstr "" + +#: templates/login.tpl:14 +msgid "Submit" +msgstr "" + +#: templates/must_login.tpl:5 +msgid "Access denied" +msgstr "" + +#: templates/must_login.tpl:6 +msgid "You must login to access this page." +msgstr "" diff --git a/phpstan.neon b/phpstan.neon index 1fc57c9..8fbe194 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -8,8 +8,22 @@ parameters: - example/includes/config.local.php - example/data/tmp/templates_c universalObjectCratesClasses: + - EesyPHP\HookEvent - EesyPHP\UrlRequest + - EesyPHP\Auth\User ignoreErrors: + - + message: "#Property EesyPHP\\\\Auth\\\\Ldap::\\$connection has unknown class Net_LDAP2 as its type\\.#" + path: src/Auth/Ldap.php + - + message: "#Call to method search\\(\\) on an unknown class Net_LDAP2\\.#" + path: src/Auth/Ldap.php + - + message: "#Call to static method connect\\(\\) on an unknown class Net_LDAP2\\.#" + path: src/Auth/Ldap.php + - + message: "#Call to static method escape\\(\\) on an unknown class Net_LDAP2_Filter\\.#" + path: src/Auth/Ldap.php - message: "#Instantiated class Mail_mime not found\\.#" path: src/Email.php diff --git a/src/App.php b/src/App.php index d0b2da5..1b56f66 100644 --- a/src/App.php +++ b/src/App.php @@ -64,6 +64,10 @@ class App { Url::init(); Url :: add_url_handler('#^$#', array('EesyPHP\\App', 'handle_homepage')); } + if (Auth :: enabled()) { + Auth :: init(); + Url :: add_url_handler('#^logout$#', array('EesyPHP\\App', 'handle_logout'), null, false); + } if (self :: get('mail.enabled', true, 'bool')) Email :: init(); if (self :: get('i18n.enabled', true, 'bool')) @@ -122,4 +126,17 @@ class App { echo "

".I18n::_("Hello world!")."

"; } + /** + * Default logout handler + * @param UrlRequest $request + * @return void + */ + public static function handle_logout($request) { + Auth::logout(); + if (Tpl::initialized()) + Tpl :: display("logout.tpl", I18n::_("Disconnected")); + else + echo "

".I18n::_("You are now disconnected.")."

"; + } + } diff --git a/src/Auth.php b/src/Auth.php new file mode 100644 index 0000000..67ddf99 --- /dev/null +++ b/src/Auth.php @@ -0,0 +1,306 @@ + + */ + private static $methods = array(); + + /** + * Method name used to authenticate current user + * @var string|null + */ + private static $logged_method = null; + + /** + * Initialized backends + * @var array + */ + private static $backends = array(); + + /** + * Current authenticated user + * @var \EesyPHP\Auth\User|null + */ + private static $user = null; + + /** + * Initialize + * @return void + */ + public static function init() { + if (!self :: enabled()) return; + self :: $methods = array(); + foreach(App::get('auth.methods', array(), 'array') as $method) { + if (!$method || !is_string($method)) { + Log::warning( + 'Auth Init: Invalid auth method retreive from configuration, ignore it: %s', + vardump($method)); + continue; + } + $class = ( + $method[0] == '\\'? + $method: + "\\EesyPHP\\Auth\\".ucfirst($method) + ); + if (!class_exists($class)) { + Log::warning( + "Auth Init: Unknown auth method '%s' retreived from configuration, ignore it", + $method + ); + continue; + } + $parents = class_parents($class); + if (!is_array($parents) || !in_array('EesyPHP\\Auth\\Method', $parents)) { + Log::warning( + 'Auth Init: Auth method %s class (%s) do not derivate from \\EesyPHP\\Auth\\Method '. + 'class, ignore it.', + $method, $class); + continue; + } + + if (!call_user_func(array($class, 'init'))) { + Log::warning( + 'Auth Init: fail to initialize auth method %s, ignore it', + $method, $class); + continue; + } + Log::trace('Auth method %s initialized (class %s)', $method, $class); + self :: $methods[strtolower($method)] = $class; + } + + self :: $backends = array(); + foreach(App::get('auth.backends', array(), 'array') as $backend) { + if (!$backend || !is_string($backend)) { + Log::warning( + 'Auth Init: Invalid auth backend retreive from configuration, ignore it: %s', + vardump($backend)); + continue; + } + $class = ( + $backend[0] == '\\'? + $backend: + "\\EesyPHP\\Auth\\".ucfirst($backend) + ); + if (!class_exists($class)) { + Log::warning( + "Auth Init: Unknown auth backend '%s' retreived from configuration, ignore it", + $backend + ); + continue; + } + $parents = class_parents($class); + if (!is_array($parents) || !in_array('EesyPHP\\Auth\\Backend', $parents)) { + Log::warning( + 'Auth Init: Auth backend %s class (%s) do not derivate from \\EesyPHP\\Auth\\Backend '. + 'class, ignore it.', + $backend, $class); + continue; + } + + if (!call_user_func(array($class, 'init'))) { + Log::warning( + 'Auth Init: fail to initialize auth backend %s, ignore it', + $backend, $class); + continue; + } + Log::trace('Auth backend %s initialized (class %s)', $backend, $class); + self :: $backends[strtolower($backend)] = $class; + } + } + + /** + * Check if authentication is enabled + * @return bool + */ + public static function enabled() { + if (!is_null(App::get('auth.enabled', null, 'bool'))) + return App::get('auth.enabled', false, 'bool'); + if (App::get('auth.methods', array(), 'array') && App::get('auth.backends', array(), 'array')) + return true; + Log :: trace('Authentication is disabled'); + return false; + } + + /** + * Check if a authentification method is enabled + * @param string $method + * @return bool + */ + public static function method_is_enabled($method) { + return array_key_exists(strtolower($method), self :: $methods); + } + + /** + * Get user by username + * @param string $username + * @param boolean $first Return only the first matched user (if authorized, optional, default: true) + * @return \EesyPHP\Auth\User|array<\EesyPHP\Auth\User>|null|false The user object if found, null it not, false in case of error + */ + public static function get_user($username, $first=true) { + if (!self :: $backends) { + Log :: warning("No auth backend registered, can't retreive user"); + return false; + } + $users = array(); + foreach (self :: $backends as $backend) { + $user = call_user_func(array($backend, 'get_user'), $username); + if ($user) $users[] = $user; + } + if (!$users) return null; + if (count($users) > 1 && !App::get('auth.allow_multiple_match', false, 'bool')) { + Log :: error('Multiple user found for username "%s":\n%s', $username, vardump($users)); + return false; + } + return $first?$users[0]:$users; + } + + /** + * Search user by username and check its password + * @param string $username The username + * @param string $password The password to check + * @return \EesyPHP\Auth\User|null|false + */ + public static function authenticate($username, $password) { + $users = self :: get_user($username, false); + if (!$users) return $users === false?false:null; + + $auth_users = array(); + foreach ($users as $user) { + if ($user->check_password($password)) + $auth_users[] = $user; + } + if (!$auth_users) return null; + if ( + count($auth_users) > 1 + && !App::get( + 'auth.allow_multiple_match_with_valid_password', + App::get('auth.allow_multiple_match', false, 'bool'), + 'bool' + ) + ) { + Log :: error( + 'Multiple user match for username "%s" and provided password:\n%s', + $username, vardump($auth_users)); + return false; + } + return $auth_users[0]; + } + + /** + * Log user + * @param bool|string $force Force user authentication: could specified desired method or true + * to use the first one (optional, default: false) + * @param null|string $with_method Specify with which method(s) login user (optional, default: all methods) + * @return \EesyPHP\Auth\User|null|false + */ + public static function login($force=false, $with_method=null) { + // Check if already logged in + if (self :: $user) + return self :: $user; + + // Check if logged in session + if (isset($_SESSION['user']) && isset($_SESSION['auth_method'])) { + $user = unserialize($_SESSION['user']); + if (is_a($user, '\\EesyPHP\\Auth\\User')) { + self :: $user = $user; + self :: $logged_method = ( + array_key_exists($_SESSION['auth_method'], self :: $methods)? + $_SESSION['auth_method']:null + ); + Log :: debug( + 'User %s authenticated from session (method %s and backend %s)', + $user->username, + self :: $logged_method?self :: $logged_method:'unknown', + $user->backend); + return $user; + } + Log::warning('Invalid user data in session, drop it'); + // Otherwise, drop user in session + unset($_SESSION['user']); + unset($_SESSION['auth_method']); + } + + if (!self :: $methods) { + Log :: warning("No auth method registered, can't authenticate users"); + return false; + } + + // Otherwise, log without enforcing by using registered methods + foreach (self :: $methods as $method => $class) { + if ($with_method && !in_array($method, array_map('strtolower', ensure_is_array($with_method)))) + continue; + $user = call_user_func(array($class, 'login')); + if ($user) { + self :: set_user($user, $method); + return $user; + } + } + + // If still not logged and force mode enable, force login using specified method (or the first one) + if ($force) { + $method = ( + is_string($force) && array_key_exists($force, self :: $methods)? + $force:key(self :: $methods) + ); + Log::debug('Force authentication using method %s', $method); + $user = call_user_func(array(self :: $methods[$method], 'login'), true); + if ($user) { + self :: set_user($user, $method); + return $user; + } + return false; + } + return null; + } + + /** + * Helper to set current authenticated user + * @param \EesyPHP\Auth\User $user The current authenticated user object + * @param string $method Method used to authenticate the user + * @return void + */ + private static function set_user($user, $method) { + Log :: debug( + 'User %s authenticated using method %s and backend %s', + $user->username, $method, $user->backend); + self :: $user = $user; + self :: $logged_method = $method; + $_SESSION['user'] = serialize($user); + $_SESSION['auth_method'] = $method; + Hook :: trigger('logged_in', array('method' => $method, 'user' => $user)); + } + + /** + * Logout + * @return void + */ + public static function logout() { + $method = ( + self :: $logged_method? + self :: $logged_method: + (isset($_SESSION['auth_method'])?$_SESSION['auth_method']:null) + ); + self :: $user = null; + self :: $logged_method = null; + if (isset($_SESSION['user'])) + unset($_SESSION['user']); + if (isset($_SESSION['auth_method'])) + unset($_SESSION['auth_method']); + if ($method) + call_user_func(array(self :: $methods[$method], 'logout')); + } + + /** + * Get current authenticated user + * @return \EesyPHP\Auth\User|null + */ + public static function user() { + return self :: $user; + } +} diff --git a/src/Auth/Backend.php b/src/Auth/Backend.php new file mode 100644 index 0000000..217d5ca --- /dev/null +++ b/src/Auth/Backend.php @@ -0,0 +1,33 @@ + $label) + if (Auth::method_is_enabled($method)) + $display_other_methods[$method] = $label; + if ( + !$user && isset($_REQUEST['method']) && + array_key_exists($_REQUEST['method'], $display_other_methods) + ) { + $user = Auth :: login($_REQUEST['method'], $_REQUEST['method']); + } + if ($user) + Url :: redirect(isset($_REQUEST['next'])?urldecode($_REQUEST['next']):null); + else + Tpl :: assign('next', (isset($_REQUEST['next'])?urldecode($_REQUEST['next']):'')); + Tpl :: assign('display_other_methods', $display_other_methods); + Tpl :: display('login.tpl', 'Connection'); + } + + /** + * Logged in hook + * @param \EesyPHP\HookEvent $event + * @return void + */ + public static function logged_in_hook($event) { + if ($event->method == 'Form' && isset($_REQUEST['next'])) + Url :: redirect(urldecode($_REQUEST['next'])); + } +} diff --git a/src/Auth/Http.php b/src/Auth/Http.php new file mode 100644 index 0000000..b0d3808 --- /dev/null +++ b/src/Auth/Http.php @@ -0,0 +1,137 @@ + '[login]', 'password' => '[password]') or false + */ + private static function get_auth_data() { + switch(self :: $method) { + case 'AUTHORIZATION': + Log :: debug("Auth HTTP: use AUTHORIZATION method"); + if (isset($_SERVER['HTTP_AUTHORIZATION']) && !empty($_SERVER['HTTP_AUTHORIZATION'])) { + $auth_data = explode(':', base64_decode(substr($_SERVER['HTTP_AUTHORIZATION'], 6))); + if (is_array($auth_data) && count($auth_data) == 2) { + return array( + 'username' => $auth_data[0], + 'password' => $auth_data[1], + ); + } + else + Log :: error("Fail to parse HTTP_AUTHORIZATION environnement variable."); + } + break; + case 'REMOTE_USER': + Log :: debug("Auth HTTP : use REMOTE_USER method"); + if (isset($_SERVER['REMOTE_USER']) && !empty($_SERVER['REMOTE_USER'])) { + return array( + 'username' => $_SERVER['REMOTE_USER'], + 'password' => false, + ); + } + break; + case 'PHP_AUTH': + default: + Log :: debug("Auth HTTP : use PHP_AUTH method"); + if (isset($_SERVER['PHP_AUTH_USER']) && !empty($_SERVER['PHP_AUTH_USER'])) { + return array( + 'username' => $_SERVER['PHP_AUTH_USER'], + 'password' => $_SERVER['PHP_AUTH_PW'], + ); + } + break; + } + Log :: trace("HTTP::get_auth_data(): no auth data found\n".vardump($_SERVER)); + return false; + } + + /** + * Log user + * @param bool $force Force user authentication + * @return \EesyPHP\Auth\User|null + */ + public static function login($force=false) { + $auth_data = self :: get_auth_data(); + if (!$auth_data) { + if ($force) self :: force_login(); + return null; + } + + if (App :: get('auth.http.trust_without_password_challenge', false, 'bool')) + $user = Auth :: get_user($auth_data['username']); + else + $user = Auth :: authenticate($auth_data['username'], $auth_data['password']); + + if (!$user && $force) + self :: force_login(); + + return $user; + } + + /** + * Force HTTP user authentification + * @return void + */ + public static function force_login() { + header('HTTP/1.1 401 Authorization Required'); + header( + sprintf('WWW-Authenticate: Basic realm="%s"', addslashes(self :: $realm)) + ); + if (Tpl::initialized()) { + Tpl :: display("must_login.tpl", I18n::_("Access denied")); + } + else { + printf("

%s

", I18n::_("Access denied")); + printf("

%s

", I18n::_("You must login to access this page.")); + printf( + "

%s

", + Url :: public_root_url(), + I18n::_("You must login to access this page.") + ); + } + exit(); + } + + /** + * Logout + * @return void + */ + public static function logout() { + self :: force_login(); + } + +} diff --git a/src/Auth/Ldap.php b/src/Auth/Ldap.php new file mode 100644 index 0000000..2480763 --- /dev/null +++ b/src/Auth/Ldap.php @@ -0,0 +1,260 @@ + + */ + private static $default_user_attributes = array( + 'uid' => array( + 'name' => 'login', + 'type' => 'string', + 'multivalued' => false, + 'default' => null, + ), + 'mail' => array( + 'type' => 'string', + 'multivalued' => false, + 'default' => null, + ), + 'cn' => array( + 'name' => 'name', + 'type' => 'string', + 'multivalued' => false, + 'default' => null, + ), + ); + + /** + * Initialize + * @return bool + */ + public static function init() { + if (!class_exists('Net_LDAP2')) { + $path = App::get('auth.ldap.netldap2_path', 'Net/LDAP2.php', 'string'); + if (!@include($path)) { + Log::error('Fail to load Net_LDAP2 (%s)', $path); + return false; + } + } + foreach(array('host', 'basedn') as $param) { + if (!App::get("auth.ldap.$param")) { + Log :: error('LDAP %s not configured. Check your configuration!', $param); + return false; + } + } + + self :: $ldap_config = array ( + 'host' => implode(' ', App :: get('auth.ldap.host', array(), 'array')), + 'basedn' => App :: get('auth.ldap.basedn', null, 'string'), + 'binddn' => App :: get('auth.ldap.bind_dn', null, 'string'), + 'bindpw' => App :: get('auth.ldap.bind_password', null, 'string'), + 'starttls' => App :: get('starttls', false, 'bool'), + ); + if ($port = App :: get('auth.ldap.port', null, 'int')) + self :: $ldap_config['port'] = $port; + return true; + } + + /** + * Connect on the LDAP directory + * @return bool + */ + private static function connect() { + if (is_a(self :: $connection, 'Net_LDAP2')) return true; + Log :: debug( + 'Connect on LDAP host "%s" as %s (base DN="%s")', + self :: $ldap_config['host'], + isset(self :: $ldap_config['binddn'])?self :: $ldap_config['binddn']:"anonymous", + self :: $ldap_config['basedn'] + ); + + // @phpstan-ignore-next-line + self :: $connection = Net_LDAP2::connect(self :: $ldap_config); + // @phpstan-ignore-next-line + if (PEAR::isError(self :: $connection)) { + Log :: error( + 'Could not connect to LDAP server (%s): %s', + self :: $ldap_config['host'], self :: $connection->getMessage()); + self :: $connection = null; + return false; + } + return true; + } + + /** + * Make a search in the LDAP directory + * @param string $filter The LDAP filter string + * @param array|null $attrs Expected attributes (optional, default: all existing attributes) + * @param string|null $basedn The base DN of the search (optional, default: configured root base + * DN of the LDAP connection) + * @param string|array|null $sorted If defined, sort return objects by specified attribute(s) + * @param array|null $options Search options as expected by NetLDAP::search() (optional, default: null) + */ + public static function search($filter, $attrs=null, $basedn=null, $sorted=null, $options=null) { + if (!self :: connect()) return false; + $options = is_array($options)?$options:array(); + if (!is_null($attrs)) + $options['attributes'] = $attrs; + + Log :: debug( + 'Run search in LDAP directory with filter "%s" on base DN "%s"', + $filter, $basedn?$basedn:"unset"); + $search = self :: $connection -> search($basedn, $filter, $options); + + // @phpstan-ignore-next-line + if (PEAR::isError($search)) { + Log :: error( + 'Error occured searching in LDAP with filter "%s" on base DN "%s": %s', + $filter, $basedn?$basedn:"unset", $search->getMessage() + ); + return false; + } + + $entries = ( + $sorted? + $search -> sorted(ensure_is_array($sorted)): + $search -> entries() + ); + + $result = array(); + foreach ($entries as $entry) + $result[$entry->dn()] = $entry -> getValues(); + + return $result; + } + + /** + * Cast an LDAP value + * @param mixed $value The raw LDAP value + * @param string $type The expected type: see cast() for supported types, but boolean value will + * be casted as LDAP boolean string. + * @return mixed The casted value + */ + public static function cast($value, $type) { + switch($type) { + case 'bool': + case 'boolean': + return $value == 'TRUE'; + case 'array_of_bool': + case 'array_of_boolean': + $values = array(); + foreach(ensure_is_array($value) as $value) + $values[] = $value == 'TRUE'; + return $values; + default: + return cast($value, $type); + } + } + + /** + * Retreive LDAP attribute value(s) from LDAP entry + * @param array $entry The LDAP entry + * @param string $attr The LDAP attribute name + * @param bool $all_values Return all values or just the first one (optional, default: false) + * @param mixed $default The default value to return if the LDAP attribute is undefined + * (optional, default: an empty array if $all_values, null otherwise) + * @param string|null $cast The expected type of value (optional, default: string) + */ + public static function get_attr($entry, $attr, $all_values=False, $default=null, $cast=null) { + $values = self :: cast( + isset($entry[$attr])?ensure_is_array($entry[$attr]):array(), + "array_of_".($cast?$cast:'string') + ); + if ($values) + return $all_values?$values:$values[0]; + if ($all_values) + return !is_null($default)?$default:array(); + return $default; + } + + /** + * Retreive a user by its username + * @param string $username + * @return \EesyPHP\Auth\User|null|false The user object if found, null it not, false in case of error + */ + public static function get_user($username) { + $attrs = App::get('auth.ldap.user_attributes', self :: $default_user_attributes, 'array'); + $users = self :: search( + str_replace( + '[username]', Net_LDAP2_Filter::escape($username), + App::get('auth.ldap.user_filter_by_uid', 'uid=[username]', 'string') + ), + array_keys($attrs), + App::get('auth.ldap.user_basedn', null, 'string') + ); + if (!is_array($users)) { + Log::warning('An error occured looking for user "%s" in LDAP directory', $username); + return false; + } + if (!$users) { + Log::debug('User "%s" not found in LDAP directory', $username); + return null; + } + if (count($users) > 1) { + Log::warning( + 'More than on users found with username "%s": %s', + $username, implode(' / ', array_keys($users)) + ); + } + $dn = key($users); + $info = array('dn' => $dn); + foreach($attrs as $attr => $attr_config) { + $info[Config::get("name", $attr, 'string', false, $attr_config)] = self :: get_attr( + $users[$dn], + $attr, + Config::get("multivalued", false, 'bool', false, $attr_config), + Config::get("default", null, null, false, $attr_config) + ); + } + Log::debug('User "%s" found in LDAP directory (%s):\n%s', $username, $dn, vardump($info)); + return new User($username, '\\EesyPHP\\Auth\\LDAP', $info); + } + + /** + * Check a user password + * @param \EesyPHP\Auth\User $user The user object + * @param string $password The password to check + * @return boolean + */ + public static function check_password($user, $password) { + $config = self :: $ldap_config; + $config['binddn'] = ( + App::get('auth.ldap.bind_with_username', false, 'bool')? + $user->username: + $user->dn + ); + $config['bindpw'] = $password; + $result = Net_LDAP2::connect($config); + // @phpstan-ignore-next-line + return !PEAR::isError($result); + } + +} diff --git a/src/Auth/Method.php b/src/Auth/Method.php new file mode 100644 index 0000000..fcda754 --- /dev/null +++ b/src/Auth/Method.php @@ -0,0 +1,34 @@ + + */ + private $info; + + /** + * Constructor + * @param string $username The username + * @param string $backend The backend class name + * @param array|null $info User info (optional) + */ + public function __construct($username, $backend, $info=null) { + $this -> username = $username; + $this -> backend = $backend; + $this -> info = is_array($info)?$info:array(); + } + + /** + * Magic method to get a dynamic property + * @param string $key The property + * @return mixed + */ + public function __get($key) { + switch ($key) { + case 'username': + return $this -> username; + case 'backend': + return $this -> backend; + default: + if (array_key_exists($key, $this -> info)) + return $this -> info[$key]; + } + Log::warning( + 'Ask for unknown user property %s:\n%s', $key, Log::get_debug_backtrace_context()); + return null; + } + + /** + * Magic method to check if a dynamic property is set + * @param string $key The property + * @return bool + */ + public function __isset($key) { + switch ($key) { + case 'username': + case 'backend': + return true; + default: + return array_key_exists($key, $this -> info); + } + } + + /** + * Check user password + * @param string $password + * @return bool + */ + public function check_password($password) { + return call_user_func( + array($this -> backend, 'check_password'), + $this, $password + ); + } + +} diff --git a/src/Log.php b/src/Log.php index 1bbb911..2ed71a2 100644 --- a/src/Log.php +++ b/src/Log.php @@ -132,7 +132,7 @@ class Log { * @return true */ public static function log($level, $message, ...$extra_args) { - global $auth_user, $argv; + global $argv; if (!array_key_exists($level, self :: $levels)) $level = self :: $default_level; if (self :: $levels[$level] < self :: $levels[self :: $level]) return true; @@ -162,8 +162,8 @@ class Log { $_SERVER['REQUEST_URI'], $_SERVER['REMOTE_ADDR'], ); - if (isset($auth_user)) - $msg[] = ($auth_user['username']?$auth_user['username']:'anonymous'); + if (Auth::enabled()) + $msg[] = (Auth::user()?Auth::user()->username:'anonymous'); $msg[] = $level; $msg[] = $message; $msg = implode(' - ', $msg)."\n"; diff --git a/src/SentryIntegration.php b/src/SentryIntegration.php index ec0927d..397efe0 100644 --- a/src/SentryIntegration.php +++ b/src/SentryIntegration.php @@ -47,11 +47,8 @@ class SentryIntegration { ]); \Sentry\configureScope(function (\Sentry\State\Scope $scope): void { - global $auth_user; $scope->setUser([ - 'id' => isset($auth_user) && $auth_user?$auth_user['uid']:null, - 'email' => isset($auth_user) && $auth_user?$auth_user['mail']:null, - 'segment' => isset($auth_user) && $auth_user?$auth_user['type']:null, + 'username' => Auth::user()?Auth::user()->username:null, 'ip_address' => php_sapi_name()=='cli'?null:$_SERVER['REMOTE_ADDR'], ]); }); diff --git a/src/Tpl.php b/src/Tpl.php index 9faf952..a0e9e43 100644 --- a/src/Tpl.php +++ b/src/Tpl.php @@ -356,7 +356,6 @@ class Tpl { * @return void */ protected static function define_common_variables($pagetitle=null) { - global $auth_user; self :: assign('public_root_url', Url :: public_root_url()); self :: assign('pagetitle', $pagetitle); self :: assign('main_pagetitle', App::get('main_pagetitle', null, 'string')); @@ -379,8 +378,8 @@ class Tpl { self :: assign('TEXT_DOMAIN', I18n :: TEXT_DOMAIN); // Authenticated user info - if (isset($auth_user)) - self :: assign('auth_user', $auth_user); + if (Auth::user()) + self :: assign('auth_user', Auth::user()); } /** diff --git a/src/Url.php b/src/Url.php index 971ed25..8a0659f 100644 --- a/src/Url.php +++ b/src/Url.php @@ -77,8 +77,8 @@ class Url { * @param array|null $additional_info Array of information to pass to the URL handler * @param boolean $authenticated Permit to define if this URL is accessible only for * authenticated users (optional, default: true if the - * special force_authentication function is defined, - * false otherwise) + * EesyPHP Authentication feature is enabled, false + * otherwise) * @param boolean $overwrite Allow overwrite if a command already exists with the * same name (optional, default: false) * @param boolean $api_mode Enable API mode (optional, default: false) @@ -88,12 +88,6 @@ class Url { public static function add_url_handler($pattern, $handler=null, $additional_info=null, $authenticated=null, $overwrite=true, $api_mode=false, $http_methods=null) { - $authenticated = ( - is_null($authenticated)? - function_exists('force_authentication'): - (bool)$authenticated - ); - // Check HTTP methods parameter if (is_null($http_methods)) $http_methods = array('GET', 'POST'); @@ -422,8 +416,7 @@ class Url { * Handle the current requested URL * * Note: if the route required that user is authenticated, this method will - * invoke the force_authentication() special function (or trigger a fatal error - * if it's not defined). + * invoke Auth::login() in force mode (or trigger a fatal error if fail). * * @param string|null $default_url The default URL if current one does not * match with any configured pattern. @@ -448,11 +441,8 @@ class Url { Tpl :: assign('request', $request); // Check authentication (if need) - if($request -> authenticated) - if (function_exists('force_authentication')) - force_authentication(); - else - Log :: fatal(I18n::_("Authentication required but force_authentication function is not defined.")); + if($request -> authenticated && !Auth::login(true)) + Log :: fatal(I18n::_("Authentication required but fail to authenticate you.")); try { call_user_func($request -> handler, $request); diff --git a/src/UrlRequest.php b/src/UrlRequest.php index 835c760..f661a8d 100644 --- a/src/UrlRequest.php +++ b/src/UrlRequest.php @@ -54,7 +54,7 @@ class UrlRequest { $this -> handler = $handler_info['handler']; $this -> authenticated = ( isset($handler_info['authenticated'])? - boolval($handler_info['authenticated']):true); + $handler_info['authenticated']:null); $this -> api_mode = ( isset($handler_info['api_mode'])? boolval($handler_info['api_mode']):false); @@ -78,7 +78,11 @@ class UrlRequest { if ($key == 'handler') return $this -> handler; if ($key == 'authenticated') - return $this -> authenticated; + return ( + is_null($this -> authenticated)? + Auth::enabled(): + (bool)$this -> authenticated + ); if ($key == 'api_mode') return $this -> api_mode; if ($key == 'referer') diff --git a/src/functions.php b/src/functions.php index d1deffd..eee6f66 100644 --- a/src/functions.php +++ b/src/functions.php @@ -99,6 +99,13 @@ function ensure_is_array($value) { * @return mixed The cast value **/ function cast($value, $type, $split=false) { + if (strpos($type, 'array_of_') === 0) { + $type = substr($type, 9); + $values = array(); + foreach(ensure_is_array($value) as $key => $value) + $values[$key] = cast($value, $type); + return $values; + } switch($type) { case 'bool': case 'boolean': diff --git a/templates/empty.tpl b/templates/empty.tpl index eedaf1a..fb2ea92 100644 --- a/templates/empty.tpl +++ b/templates/empty.tpl @@ -53,11 +53,11 @@ diff --git a/templates/login.tpl b/templates/login.tpl new file mode 100644 index 0000000..f8edb2e --- /dev/null +++ b/templates/login.tpl @@ -0,0 +1,22 @@ +{extends file='Tpl:empty.tpl'} +{block name="pagetitle"}

{t domain=$CORE_TEXT_DOMAIN}Connection{/t}

{/block} +{block name="content"} +
+ +
+ + +
+
+ + +
+ + {foreach $display_other_methods as $method => $name} + {$name} + {/foreach} +
+{/block} +{* +# vim: autoindent expandtab tabstop=2 shiftwidth=2 softtabstop=2 +*} diff --git a/templates/logout.tpl b/templates/logout.tpl new file mode 100644 index 0000000..eca5cd3 --- /dev/null +++ b/templates/logout.tpl @@ -0,0 +1,11 @@ +{extends file='Tpl:empty.tpl'} +{block name="pagetitle"}{/block} +{block name="content"} +
+

{t domain=$CORE_TEXT_DOMAIN}Disconnected{/t}

+

{t escape=off domain=$CORE_TEXT_DOMAIN}You are now disconnected.{/t}

+
+{/block} +{* +# vim: autoindent expandtab tabstop=2 shiftwidth=2 softtabstop=2 +*} diff --git a/templates/must_login.tpl b/templates/must_login.tpl new file mode 100644 index 0000000..945f03c --- /dev/null +++ b/templates/must_login.tpl @@ -0,0 +1,11 @@ +{extends file='Tpl:empty.tpl'} +{block name="pagetitle"}{/block} +{block name="content"} +
+

{t domain=$CORE_TEXT_DOMAIN}Access denied{/t}

+

{t escape=off domain=$CORE_TEXT_DOMAIN}You must login to access this page.{/t}

+
+{/block} +{* +# vim: autoindent expandtab tabstop=2 shiftwidth=2 softtabstop=2 +*}