Merge branch 'v2.0'

This commit is contained in:
Christophe Maudoux 2019-08-25 18:47:06 +02:00
commit 1212cd9ba2
68 changed files with 429 additions and 177 deletions

View File

@ -35,6 +35,7 @@ UNCOMPRESS=tar xzf
LISTCOMPRESSED=tar tzf
COMPRESSSUFFIX=tar.gz
NGINX=/usr/sbin/nginx
UGLIFYJSVERSION:=$(shell uglifyjs --version|perl -pe 's/^[^\d]*(\d).*$$/$$1/')
# Default directories install
# ---------------------------
@ -311,7 +312,11 @@ $(SRCMANAGERDIR)/site/htdocs/static/js/%.js: $(SRCMANAGERDIR)/site/coffee/%.coff
%.min.js: %.js
@echo "Compressing $*.js"
@uglifyjs $*.js --compress --mangle --comments='/Copyr/i' --source-map $*.min.js.map -o $*.min.js
if test "$(UGLIFYJSVERSION)" = 2; then \
uglifyjs $*.js --compress --mangle --comments='/Copyr/i' --source-map $*.min.js.map -o $*.min.js; \
else \
uglifyjs $*.js --compress --mangle --comments='/Copyr/i' --source-map -o $*.min.js; \
fi
fastcgi-server/man/llng-fastcgi-server.1p: fastcgi-server/sbin/llng-fastcgi-server
@echo Update FastCGI server man page

View File

@ -4,7 +4,7 @@
# To insert LLNG user id in Apache logs, declare this format and use it in
# CustomLog directive
#LogFormat "%v:%p %h %l %{Lm-Remote-User}o %t \"%r\" %>s %O" llng
#LogFormat "%v:%p %h %l %{Lm-Remote-User}o %t \"%r\" %>s %O %{Lm-Remote-Custom}o" llng
# Manager virtual host (manager.__DNSDOMAIN__)
<VirtualHost __VHOSTLISTEN__>

View File

@ -7,7 +7,7 @@
# To insert LLNG user id in Apache logs, declare this format and use it in
# CustomLog directive
#LogFormat "%v:%p %h %l %{Lm-Remote-User}o %t \"%r\" %>s %O" llng
#LogFormat "%v:%p %h %l %{Lm-Remote-User}o %t \"%r\" %>s %O %{Lm-Remote-Custom}o" llng
# Manager virtual host (manager.__DNSDOMAIN__)
<VirtualHost __VHOSTLISTEN__>

View File

@ -7,7 +7,7 @@
# To insert LLNG user id in Apache logs, declare this format and use it in
# CustomLog directive
#LogFormat "%v:%p %h %l %{Lm-Remote-User}o %t \"%r\" %>s %O" llng
#LogFormat "%v:%p %h %l %{Lm-Remote-User}o %t \"%r\" %>s %O %{Lm-Remote-Custom}o" llng
# Manager virtual host (manager.__DNSDOMAIN__)
<VirtualHost __VHOSTLISTEN__>

View File

@ -1,6 +1,6 @@
log_format lm_combined '$remote_addr - $lmremote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent"';
'"$http_referer" "$http_user_agent" $lmremote_custom';
log_format lm_app '$remote_addr - $upstream_http_lm_remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent"';
'"$http_referer" "$http_user_agent" $lmremote_custom';

View File

@ -4,7 +4,7 @@
# To insert LLNG user id in Apache logs, declare this format and use it in
# CustomLog directive
#LogFormat "%v:%p %h %l %{Lm-Remote-User}o %t \"%r\" %>s %O" llng
#LogFormat "%v:%p %h %l %{Lm-Remote-User}o %t \"%r\" %>s %O %{Lm-Remote-Custom}o" llng
# Portal Virtual Host (auth.__DNSDOMAIN__)
<VirtualHost __VHOSTLISTEN__>

View File

@ -7,7 +7,7 @@
# To insert LLNG user id in Apache logs, declare this format and use it in
# CustomLog directive
#LogFormat "%v:%p %h %l %{Lm-Remote-User}o %t \"%r\" %>s %O" llng
#LogFormat "%v:%p %h %l %{Lm-Remote-User}o %t \"%r\" %>s %O %{Lm-Remote-Custom}o" llng
# Portal Virtual Host (auth.__DNSDOMAIN__)
<VirtualHost __VHOSTLISTEN__>

View File

@ -7,7 +7,7 @@
# To insert LLNG user id in Apache logs, declare this format and use it in
# CustomLog directive
#LogFormat "%v:%p %h %l %{Lm-Remote-User}o %t \"%r\" %>s %O" llng
#LogFormat "%v:%p %h %l %{Lm-Remote-User}o %t \"%r\" %>s %O %{Lm-Remote-Custom}o" llng
# Portal Virtual Host (auth.__DNSDOMAIN__)
<VirtualHost __VHOSTLISTEN__>

View File

@ -58,6 +58,7 @@ server {
##################################
auth_request /lmauth;
auth_request_set $lmremote_user $upstream_http_lm_remote_user;
auth_request_set $lmremote_custom $upstream_http_lm_remote_custom;
auth_request_set $lmlocation $upstream_http_location;
# If CDA is used, uncomment this
#auth_request_set $cookie_value $upstream_http_set_cookie;
@ -88,8 +89,9 @@ server {
# Uncomment this if you use https only
#add_header Strict-Transport-Security "max-age=15768000";
# Set REMOTE_USER (for FastCGI apps only)
# Set REMOTE_USER and REMOTE_CUSTOM (for FastCGI apps only)
#fastcgi_param REMOTE_USER $lmremote_user;
#fastcgi_param REMOTE_CUSTOM $lmremote_custom;
}
# Handle test CGI
@ -100,6 +102,7 @@ server {
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_split_path_info ^(.*\.pl)(/.+)$;
fastcgi_param REMOTE_USER $lmremote_user;
fastcgi_param REMOTE_CUSTOM $lmremote_custom;
# Or with uWSGI
#include /etc/nginx/uwsgi_params;

View File

@ -25,7 +25,7 @@ LoadModule headers_module /usr/lib/apache2/modules/mod_headers.so
</Directory>
LogFormat "%v:%p %h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" vhost_combined
LogFormat "%h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" combined
LogFormat "%v:%p %h %l %{Lm-Remote-User}o %t \"%r\" %>s %O" llng
LogFormat "%v:%p %h %l %{Lm-Remote-User}o %t \"%r\" %>s %O %{Lm-Remote-Custom}o" llng
ErrorLog conf/apache2.log
CustomLog conf/apache2.log vhost_combined

View File

@ -207,5 +207,6 @@
"sessionDataToRemember": {},
"timeout": 72000,
"userDB": "Same",
"whatToTrace": "_whatToTrace"
"whatToTrace": "_whatToTrace",
"customToTrace": "mail"
}

View File

@ -37,6 +37,7 @@ server {
try_files $uri $uri/ =404;
auth_request /lmauth;
auth_request_set $lmremote_user $upstream_http_lm_remote_user;
auth_request_set $lmremote_custom $upstream_http_lm_remote_custom;
auth_request_set $lmlocation $upstream_http_location;
error_page 401 $lmlocation;
include conf/nginx-lua-headers.conf;

View File

@ -372,10 +372,11 @@ languages = fr, en, it, vi, ar
; Manager modules enabled
; Set here the list of modules you want to see in manager interface
; The first will be used as default module displayed
enabledModules = conf, sessions, notifications, 2ndFA, viewer
;enabledModules = conf, sessions, notifications, 2ndFA, viewer
enabledModules = conf, sessions, notifications, 2ndFA
; To avoid restricted users to edit configuration, defaulModule MUST be different than 'conf'
; 'viewer' is set by default
; 'conf' is set by default
;defaultModule = viewer
; Viewer module allows us to edit configuration in read-only mode
@ -384,7 +385,7 @@ enabledModules = conf, sessions, notifications, 2ndFA, viewer
;viewerAllowDiff = $uid ne 'dwho'
;
; Viewer options - Default values
;viewerHiddenKeys = samlIDPMetaDataNodes samlSPMetaDataNodes
;viewerHiddenKeys = samlIDPMetaDataNodes samlSPMetaDataNodes managerPassword ManagerDn globalStorageOptions persistentStorageOptions
;viewerAllowBrowser = 0
;viewerAllowDiff = 0

View File

@ -129,7 +129,7 @@ sub defaultValues {
'ldapVersion' => 3,
'linkedInAuthnLevel' => 1,
'linkedInFields' => 'id,first-name,last-name,email-address',
'linkedInScope' => 'r_basicprofile r_emailaddress',
'linkedInScope' => 'r_liteprofile r_emailaddress',
'linkedInUserField' => 'emailAddress',
'localSessionStorage' => 'Cache::FileCache',
'localSessionStorageOptions' => {

View File

@ -276,11 +276,11 @@ sub getMod {
my ( $self, $req ) = @_;
my ( $s, $m );
unless ( $s = $req->params('sessionType') ) {
$self->error('Session type is required');
$self->error($req->error('Session type is required'));
return ();
}
unless ( $m = $self->sessionTypes->{$s} ) {
$self->error('Unknown (or unconfigured) session type');
$self->error($req->error('Unknown (or unconfigured) session type'));
return ();
}
if ( my $kind = $req->params('kind') ) {

View File

@ -92,6 +92,10 @@ sub handler {
if ( $hdrs{'Lm-Remote-User'} ) {
$r->user( $hdrs{'Lm-Remote-User'} );
}
if ( $hdrs{'Lm-Remote-Custom'} ) {
$r->subprocess_env( REMOTE_CUSTOM => $hdrs{'Lm-Remote-Custom'} );
}
my $i = 1;
while ( $hdrs{"Headername$i"} ) {
$r->headers_in->set( $hdrs{"Headername$i"} => $hdrs{"Headervalue$i"} )

View File

@ -81,6 +81,15 @@ sub set_user {
$request->env->{'psgi.r'}->user($user);
}
## @method void set_custom(string custom)
# sets remote_custom
# @param custom string custom_header
sub set_custom {
my ( $class, $request, $custom ) = @_;
$request->env->{'psgi.r'}->subprocess_env( REMOTE_CUSTOM => $custom )
if defined $custom;
}
## @method void set_header_in(hash headers)
# sets or modifies request headers
# @param headers hash containing header names => header value

View File

@ -176,6 +176,13 @@ sub user {
|| _whatToTrace => 'anonymous' };
}
## @method hashRef custom()
# @return hash of custom data
sub custom {
my ( $self, $req ) = @_;
return { $Lemonldap::NG::Handler::Main::tsv->{customToTrace} };
}
## @method string userId()
# @return user identifier to log
sub userId {
@ -195,7 +202,7 @@ sub group {
}
## @method PSGI::Response sendError($req,$err,$code)
# Add user di to $err before calling Lemonldap::NG::Common::PSGI::sendError()
# Add user id to $err before calling Lemonldap::NG::Common::PSGI::sendError()
# @param $req Lemonldap::NG::Common::PSGI::Request
# @param $err String to push
# @code int HTTP error code (default to 500)

View File

@ -197,7 +197,7 @@ sub defaultValuesInit {
securedCookie timeout timeoutActivity
timeoutActivityInterval useRedirectOnError useRedirectOnForbidden
useSafeJail whatToTrace handlerInternalCache
handlerServiceTokenTTL
handlerServiceTokenTTL customToTrace
)
);

View File

@ -147,6 +147,7 @@ sub run {
# ACCOUNTING (1. Inform web server)
$class->set_user( $req, $session->{ $class->tsv->{whatToTrace} } );
$class->set_custom( $req, $session->{ $class->tsv->{customToTrace} } );
# AUTHORIZATION
return ( $class->forbidden( $req, $session ), $session )

View File

@ -41,6 +41,15 @@ sub set_user {
push @{ $req->{respHeaders} }, 'Lm-Remote-User' => $user;
}
## @method void set_custom(string custom)
# sets remote_custom in response headers
# @param custom string custom_value
sub set_custom {
my ( $class, $req, $custom ) = @_;
push @{ $req->{respHeaders} }, 'Lm-Remote-Custom' => $custom
if defined $custom;
}
## @method void set_header_in(hash headers)
# sets or modifies request headers
# @param headers hash containing header names => header value

View File

@ -39,6 +39,7 @@ sub unset_header_in {
*setServerSignature = *Lemonldap::NG::Handler::PSGI::Main::setServerSignature;
*thread_share = *Lemonldap::NG::Handler::PSGI::Main::thread_share;
*set_user = *Lemonldap::NG::Handler::PSGI::Main::set_user;
*set_custom = *Lemonldap::NG::Handler::PSGI::Main::set_custom;
*set_header_out = *Lemonldap::NG::Handler::PSGI::Main::set_header_out;
*is_initial_req = *Lemonldap::NG::Handler::PSGI::Main::is_initial_req;
*print = *Lemonldap::NG::Handler::PSGI::Main::print;

View File

@ -72,7 +72,7 @@ sub handler {
my $i = 0;
while ( my $k = shift @$hdrs ) {
my $v = shift @$hdrs;
if ( $k =~ /^(?:Lm-Remote-User|Cookie)$/ ) {
if ( $k =~ /^(?:Lm-Remote-(?:User|Custom)|Cookie)$/ ) {
push @convertedHdrs, $k, $v;
}
else {

View File

@ -52,7 +52,7 @@ sub init {
return 0;
}
$self->{enabledModules} ||= "conf, sessions, notifications, 2ndFA, viewer";
$self->{enabledModules} ||= "conf, sessions, notifications, 2ndFA";
my @links;
my @enabledModules =
map { push @links, $_; "Lemonldap::NG::Manager::" . ucfirst($_) }
@ -88,10 +88,10 @@ sub init {
);
# Avoid restricted users to access configuration by default route
my $defaultMod = $self->{defaultModule} || 'viewer';
my $defaultMod = $self->{defaultModule} || 'conf';
$self->logger->debug("Default module -> $defaultMod");
my ($index) =
grep { $working[$_] =~ /::$defaultMod$/ } ( 0 .. $#working );
grep { $working[$_] =~ /::$defaultMod$/i } ( 0 .. $#working );
$index //= $#working;
$self->logger->debug("Default index -> $index");
$self->defaultRoute( $working[$index]->defaultRoute );

View File

@ -1022,6 +1022,9 @@ qr/(?:(?:https?):\/\/(?:(?:(?:(?:(?:(?:[a-zA-Z0-9][-a-zA-Z0-9]*)?[a-zA-Z0-9])[.]
'customRegister' => {
'type' => 'text'
},
'customToTrace' => {
'type' => 'lmAttrOrMacro'
},
'customUserDB' => {
'type' => 'text'
},
@ -1555,7 +1558,7 @@ m[^(?:ldapi://[^/]*/?|\w[\w\-\.]*(?::\d{1,5})?|ldap(?:s|\+tls)?://\w[\w\-\.]*(?:
'type' => 'text'
},
'linkedInScope' => {
'default' => 'r_basicprofile r_emailaddress',
'default' => 'r_liteprofile r_emailaddress',
'type' => 'text'
},
'linkedInUserField' => {

View File

@ -891,6 +891,11 @@ sub attributes {
documentation => 'Session parameter used to fill REMOTE_USER',
flags => 'hp',
},
customToTrace => {
type => 'lmAttrOrMacro',
documentation => 'Session parameter used to fill REMOTE_CUSTOM',
flags => 'hp',
},
lwpOpts => {
type => 'keyTextContainer',
documentation => 'Options given to LWP::UserAgent',
@ -3163,7 +3168,7 @@ m{^(?:ldapi://[^/]*/?|\w[\w\-\.]*(?::\d{1,5})?|ldap(?:s|\+tls)?://\w[\w\-\.]*(?:
},
linkedInUserField => { type => 'text', default => 'emailAddress' },
linkedInScope =>
{ type => 'text', default => 'r_basicprofile r_emailaddress' },
{ type => 'text', default => 'r_liteprofile r_emailaddress' },
# WebID
webIDAuthnLevel => {

View File

@ -250,7 +250,9 @@ sub cTrees {
]
},
],
casAppMetaDataNode => [ {
casAppMetaDataNode => [
'casAppMetaDataExportedVars',
{
title => 'casAppMetaDataOptions',
form => 'simpleInputContainer',
nodes => [
@ -259,7 +261,6 @@ sub cTrees {
'casAppMetaDataOptionsRule'
]
},
'casAppMetaDataExportedVars',
],
};
}

View File

@ -511,7 +511,7 @@ sub tree {
title => 'logParams',
help => 'logs.html',
form => 'simpleInputContainer',
nodes => [ 'whatToTrace', 'hiddenAttributes' ]
nodes => [ 'whatToTrace', 'customToTrace', 'hiddenAttributes' ]
},
{
title => 'cookieParams',

View File

@ -383,6 +383,7 @@ sub _scanNodes {
}
elsif ($h) {
hdebug(' opened');
$self->confChanged(1);
$self->set( $target, $key, $leaf->{title},
$leaf->{data} );
}
@ -451,6 +452,7 @@ sub _scanNodes {
}
elsif ($h) {
hdebug(' opened');
$self->confChanged(1);
$self->set( $target, $key, $leaf->{title},
$leaf->{data} );
}

View File

@ -1,4 +1,4 @@
// Generated by CoffeeScript 1.12.7
// Generated by CoffeeScript 1.12.8
/*
* 2ndFA Session explorer

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -10,28 +10,6 @@ function templates(tpl,key) {
switch(tpl){
case 'casAppMetaDataNode':
return [
{
"_nodes" : [
{
"get" : tpl+"s/"+key+"/"+"casAppMetaDataOptionsService",
"id" : tpl+"s/"+key+"/"+"casAppMetaDataOptionsService",
"title" : "casAppMetaDataOptionsService"
},
{
"get" : tpl+"s/"+key+"/"+"casAppMetaDataOptionsUserAttribute",
"id" : tpl+"s/"+key+"/"+"casAppMetaDataOptionsUserAttribute",
"title" : "casAppMetaDataOptionsUserAttribute"
},
{
"get" : tpl+"s/"+key+"/"+"casAppMetaDataOptionsRule",
"id" : tpl+"s/"+key+"/"+"casAppMetaDataOptionsRule",
"title" : "casAppMetaDataOptionsRule"
}
],
"id" : "casAppMetaDataOptions",
"title" : "casAppMetaDataOptions",
"type" : "simpleInputContainer"
},
{
"cnodes" : tpl+"s/"+key+"/"+"casAppMetaDataExportedVars",
"default" : [
@ -57,6 +35,28 @@ function templates(tpl,key) {
"id" : tpl+"s/"+key+"/"+"casAppMetaDataExportedVars",
"title" : "casAppMetaDataExportedVars",
"type" : "keyTextContainer"
},
{
"_nodes" : [
{
"get" : tpl+"s/"+key+"/"+"casAppMetaDataOptionsService",
"id" : tpl+"s/"+key+"/"+"casAppMetaDataOptionsService",
"title" : "casAppMetaDataOptionsService"
},
{
"get" : tpl+"s/"+key+"/"+"casAppMetaDataOptionsUserAttribute",
"id" : tpl+"s/"+key+"/"+"casAppMetaDataOptionsUserAttribute",
"title" : "casAppMetaDataOptionsUserAttribute"
},
{
"get" : tpl+"s/"+key+"/"+"casAppMetaDataOptionsRule",
"id" : tpl+"s/"+key+"/"+"casAppMetaDataOptionsRule",
"title" : "casAppMetaDataOptionsRule"
}
],
"id" : "casAppMetaDataOptions",
"title" : "casAppMetaDataOptions",
"type" : "simpleInputContainer"
}
]
;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
// Generated by CoffeeScript 1.12.7
// Generated by CoffeeScript 1.12.8
/*
LemonLDAP::NG Manager client

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
// Generated by CoffeeScript 1.12.7
// Generated by CoffeeScript 1.12.8
/*
* Sessions explorer

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
// Generated by CoffeeScript 1.12.7
// Generated by CoffeeScript 1.12.8
/*
LemonLDAP::NG Viewer client

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -205,6 +205,7 @@
"customPassword":"وحدة كلمة المرورالمخصصة",
"customPortalSkin":"غلاف البوابة مخصص",
"customRegister":"وحدة تسجيل مخصص",
"customToTrace":"REMOTE_CUSTOM",
"customUserDB":"وحدة قاعدة البيانات المخصصة",
"date":"تاريخ",
"dbiAuthChain":"سلسلة",

View File

@ -205,6 +205,7 @@
"customPassword":"Custom password module",
"customPortalSkin":"Custom portal skin",
"customRegister":"Custom register module",
"customToTrace":"REMOTE_CUSTOM",
"customUserDB":"Custom user DB module",
"date":"Datum",
"dbiAuthChain":"Chain",

View File

@ -205,6 +205,7 @@
"customPassword":"Custom password module",
"customPortalSkin":"Custom portal skin",
"customRegister":"Custom register module",
"customToTrace":"REMOTE_CUSTOM",
"customUserDB":"Custom user DB module",
"date":"Date",
"dbiAuthChain":"Chain",

View File

@ -205,6 +205,7 @@
"customPassword":"Module de mots-de-passe personnalisé",
"customPortalSkin":"Style personnalisé du portail",
"customRegister":"Module d'enregistrement personnalisé",
"customToTrace":"REMOTE_CUSTOM",
"customUserDB":"Module BD utilisateurs personnalisé",
"date":"Date",
"dbiAuthChain":"Chaîne",

View File

@ -205,6 +205,7 @@
"customPassword":"Personalizza il modulo password",
"customPortalSkin":"Personalizza faccia del portale ",
"customRegister":"Personalizza modulo di registro",
"customToTrace":"REMOTE_CUSTOM",
"customUserDB":"Personalizza modulo utente DB",
"date":"Data",
"dbiAuthChain":"Catena",

View File

@ -205,6 +205,7 @@
"customPassword":"Mô đun mật khẩu tùy chỉnh",
"customPortalSkin":"Tùy chỉnh giao diện cổng thông tin",
"customRegister":"Module đăng ký tùy chỉnh",
"customToTrace":"REMOTE_CUSTOM",
"customUserDB":"Mô đun DB người dùng tùy chỉnh",
"date":"Ngày",
"dbiAuthChain":"Chuỗi",

View File

@ -205,6 +205,7 @@
"customPassword":"Custom password module",
"customPortalSkin":"Custom portal skin",
"customRegister":"Custom register module",
"customToTrace":"REMOTE_CUSTOM",
"customUserDB":"Custom user DB module",
"date":"日期",
"dbiAuthChain":"Chain",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -21,7 +21,7 @@
<!-- Last buttons, available languages -->
</div>
<ul class="hidden-xs nav navbar-nav" role="grid">
<li ng-repeat="l in links" id="l in links"><a href="{{l.target}}" role="row" ng-mouseup="clickStyle={color: '#ffb84d'}"><strong><i ng-if="activeModule == l.title" ng-style="myStyle" class="glyphicon glyphicon-{{l.icon}}"></i><i ng-if="activeModule != l.title" class="glyphicon glyphicon-{{l.icon}}" ng-style="clickStyle"></i> <span ng-if="activeModule == l.title" ng-style="myStyle" ng-bind="translate(l.title)"></span><span ng-if="activeModule != l.title" ng-bind="translate(l.title)" ng-style="clickStyle"></span></strong></a></li>
<li ng-repeat="l in links" id="l in links"><a href="{{l.target}}" role="row"><strong><i ng-if="activeModule == l.title" ng-style="myStyle" class="glyphicon glyphicon-{{l.icon}}"></i><i ng-if="activeModule != l.title" class="glyphicon glyphicon-{{l.icon}}" ng-style="clickStyle"></i> <span ng-if="activeModule == l.title" ng-style="myStyle" ng-bind="translate(l.title)"></span><span ng-if="activeModule != l.title" ng-bind="translate(l.title)" ng-style="clickStyle"></span></strong></a></li>
</ul>
<ul class="hidden-xs nav navbar-nav navbar-right">
<li uib-dropdown>

View File

@ -119,13 +119,6 @@ sub getForm {
return [ split /[, ]\s*/, $self->conf->{combinationForms} ]
if ( $self->conf->{combinationForms} );
if ( $req->{error} > PE_OK ) {
$self->logger->notice('Start over combination schema');
my $stack = $self->stackSub->( $req->env );
my ( $res, $name ) = $stack->[0]->[0]->( 'getForm', $req );
return $res;
}
my ( $nb, $stack ) = (
$req->data->{dataKeep}->{combinationTry},
$req->data->{combinationStack}
@ -198,6 +191,7 @@ sub try {
# If more than 1 scheme is available
my ( $res, $name );
if ( $nb < @$stack - 1 ) {
# TODO: change logLevel for userLog()

View File

@ -50,7 +50,16 @@ has linkedInPeopleEndpoint => (
lazy => 1,
default => sub {
$_[0]->conf->{linkedInPeopleEndpoint}
|| 'https://api.linkedin.com/v1/people/';
|| 'https://api.linkedin.com/v2/me';
}
);
has linkedInEmailEndpoint => (
is => 'ro',
lazy => 1,
default => sub {
$_[0]->conf->{linkedInEmailEndpoint}
|| 'https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))';
}
);
@ -123,13 +132,11 @@ sub extractFormInfo {
$self->logger->debug("Get access token $access_token from LinkedIn");
# Build People EndPoint URI
my $linkedInPeopleEndpoint =
$self->linkedInPeopleEndpoint . "~:("
. $self->conf->{linkedInFields}
. ")?format=json";
# Call People EndPoint URI
$self->logger->debug(
"Call LinkedIn People Endpoint " . $self->linkedInPeopleEndpoint );
my $people_response = $self->ua->get( $linkedInPeopleEndpoint,
my $people_response = $self->ua->get( $self->linkedInPeopleEndpoint,
"Authorization" => "Bearer $access_token" );
if ( $people_response->is_error ) {
@ -141,6 +148,9 @@ sub extractFormInfo {
my $people_content = $people_response->decoded_content;
$self->logger->debug(
"Response from LinkedIn People API: $people_content");
eval {
$json_hash = from_json( $people_content, { allow_nonref => 1 } );
};
@ -153,6 +163,39 @@ sub extractFormInfo {
$req->data->{linkedInData}->{$_} = $json_hash->{$_};
}
# Call Email EndPoint URI
if ( $self->conf->{linkedInScope} =~ /r_emailaddress/ ) {
$self->logger->debug( "Call LinkedIn Email Endpoint "
. $self->linkedInEmailEndpoint );
my $email_response = $self->ua->get( $self->linkedInEmailEndpoint,
"Authorization" => "Bearer $access_token" );
if ( $email_response->is_error ) {
$self->logger->error(
"Bad authorization response: " . $email_response->message );
$self->logger->debug( $email_response->content );
return PE_ERROR;
}
my $email_content = $email_response->decoded_content;
$self->logger->debug(
"Response from LinkedIn Email API: $email_content");
eval {
$json_hash = from_json( $email_content, { allow_nonref => 1 } );
};
if ($@) {
$self->logger->error("Unable to decode JSON $email_content");
return PE_ERROR;
}
$req->data->{linkedInData}->{"emailAddress"} =
$json_hash->{"elements"}->[0]{"handle~"}->{"emailAddress"};
}
$req->user(
$req->data->{linkedInData}->{ $self->conf->{linkedInUserField} } );

View File

@ -38,6 +38,14 @@ sub authenticate {
$self->setSecurity($req);
return PE_ERROR;
}
$self->logger->debug( "REST result:" . ( $res->{result} || 'undef' ) );
if ( $res->{info} ) {
eval {
$self->logger->debug(" $_ => $res->{info}->{$_}")
foreach ( keys %{ $res->{info} } );
};
}
$self->logger->error( 'No "info": ' . $@ ) if ($@);
unless ( $res->{result} ) {
$self->userLogger->warn(
"Bad credentials for " . $req->user . ' (' . $req->address . ')' );

View File

@ -46,6 +46,10 @@ sub setAuthSessionInfo {
PE_OK;
}
sub getDisplayType {
return "logo";
}
sub authLogout {
my ( $self, $req ) = @_;
PE_OK;

View File

@ -605,14 +605,13 @@ sub run {
if ( $flow eq "authorizationcode" ) {
# Store data in session
my $codeSession = $self->getOpenIDConnectSession(
undef,
my $codeSession = $self->newAuthorizationCode(
$rp,
{
redirect_uri => $oidc_request->{'redirect_uri'},
scope => $oidc_request->{'scope'},
client_id => $client_id,
user_session_id => $req->id,
_utime => time,
nonce => $oidc_request->{'nonce'},
code_challenge => $oidc_request->{'code_challenge'},
code_challenge_method =>
@ -648,13 +647,12 @@ sub run {
# Store data in access token
# Generate access_token
my $accessTokenSession = $self->getOpenIDConnectSession(
undef,
my $accessTokenSession = $self->newAccessToken(
$rp,
{
scope => $oidc_request->{'scope'},
rp => $rp,
user_session_id => $req->id,
_utime => time,
}
);
@ -774,14 +772,13 @@ sub run {
my ($hash_level) = ( $alg =~ /(?:\w{2})(\d{3})/ );
# Store data in session
my $codeSession = $self->getOpenIDConnectSession(
undef,
my $codeSession = $self->newAuthorizationCode(
$rp,
{
redirect_uri => $oidc_request->{'redirect_uri'},
client_id => $client_id,
scope => $oidc_request->{'scope'},
user_session_id => $req->id,
_utime => time,
nonce => $oidc_request->{'nonce'},
}
);
@ -798,13 +795,12 @@ sub run {
if ( $response_type =~ /\btoken\b/ ) {
# Generate access_token
my $accessTokenSession = $self->getOpenIDConnectSession(
undef,
my $accessTokenSession = $self->newAccessToken(
$rp,
{
scope => $oidc_request->{'scope'},
rp => $rp,
user_session_id => $req->id,
_utime => time,
}
);
@ -1056,7 +1052,7 @@ sub token {
$self->logger->debug("OpenID Connect Code: $code");
my $codeSession = $self->getOpenIDConnectSession($code);
my $codeSession = $self->getAuthorizationCode($code);
unless ($codeSession) {
$self->logger->error("Unable to find OIDC session $code");
@ -1115,13 +1111,12 @@ sub token {
$self->logger->debug("Found corresponding user: $user_id");
# Generate access_token
my $accessTokenSession = $self->getOpenIDConnectSession(
undef,
my $accessTokenSession = $self->newAccessToken(
$rp,
{
scope => $codeSession->data->{scope},
rp => $rp,
user_session_id => $apacheSession->id,
_utime => time,
}
);
@ -1210,7 +1205,7 @@ sub userInfo {
$self->logger->debug("Received Access Token $access_token");
my $accessTokenSession = $self->getOpenIDConnectSession($access_token);
my $accessTokenSession = $self->getAccessToken($access_token);
unless ($accessTokenSession) {
$self->userLogger->error(

View File

@ -630,15 +630,65 @@ sub decodeJSON {
return $json_hash;
}
# Create a new Authorization Code
# @param info hashref of session info
# @return new Lemonldap::NG::Common::Session object
sub newAuthorizationCode {
my ( $self, $rp, $info ) = @_;
return $self->getOpenIDConnectSession( undef, "authorization_code", undef,
$info );
}
# Get existing Authorization Code
# @param id
# @return new Lemonldap::NG::Common::Session object
sub getAuthorizationCode {
my ( $self, $id ) = @_;
return $self->getOpenIDConnectSession( $id, "authorization_code" );
}
# Create a new Access Token
# @param info hashref of session info
# @return new Lemonldap::NG::Common::Session object
sub newAccessToken {
my ( $self, $rp, $info ) = @_;
return $self->getOpenIDConnectSession(
undef,
"access_token",
$self->conf->{oidcRPMetaDataOptions}->{$rp}
->{oidcRPMetaDataOptionsAccessTokenExpiration},
$info
);
}
# Get existing Access Token
# @param id
# @return new Lemonldap::NG::Common::Session object
sub getAccessToken {
my ( $self, $id ) = @_;
return $self->getOpenIDConnectSession( $id, "access_token" );
}
# Try to recover the OpenID Connect session corresponding to id and return session
# If id is set to undef, return a new session
# @return Lemonldap::NG::Common::Session object
sub getOpenIDConnectSession {
my ( $self, $id, $info ) = @_;
my ( $self, $id, $type, $ttl, $info ) = @_;
my %storage = (
storageModule => $self->conf->{oidcStorage},
storageModuleOptions => $self->conf->{oidcStorageOptions},
);
$ttl ||= $self->conf->{timeout};
unless ( $storage{storageModule} ) {
%storage = (
storageModule => $self->conf->{globalStorage},
@ -652,7 +702,17 @@ sub getOpenIDConnectSession {
cacheModuleOptions => $self->conf->{localSessionStorageOptions},
id => $id,
kind => $self->sessionKind,
( $info ? ( info => $info ) : () ),
(
$info
? (
info => {
_type => $type,
_utime => time + $ttl - $self->conf->{timeout},
%{$info}
}
)
: ()
),
}
);
@ -668,6 +728,26 @@ sub getOpenIDConnectSession {
return undef;
}
if ($id) {
my $storedType = $oidcSession->{data}->{_type};
# Only check if a type is set in DB, for backward compatibility
if ( $storedType and $type ne $storedType ) {
$self->logger->error( "Wrong OpenID session type: "
. $oidcSession->{data}->{_type}
. ". Expected: "
. $type );
return undef;
}
}
# Make sure the token is still valid, we already compensated for
# different TTLs when storing _utime
if ( time > ( $oidcSession->{data}->{_utime} + $self->conf->{timeout} ) ) {
$self->logger->error("Session $id has expired");
return undef;
}
return $oidcSession;
}
@ -980,8 +1060,8 @@ sub createHash {
# @param fragment Set to true to return fragment component
# @return void
sub returnRedirectError {
my ( $self, $req, $redirect_url, $error, $error_description, $error_uri,
$state, $fragment )
my ( $self, $req, $redirect_url, $error, $error_description,
$error_uri, $state, $fragment )
= @_;
my $urldc =
@ -1401,7 +1481,10 @@ sub addRouteFromConf {
$self->logger->error("$_ parameter not defined");
next;
}
$self->$adder( $self->path => { $path => $sub }, [ 'GET', 'POST' ] );
$self->$adder(
$self->path => { $path => $sub },
[ 'GET', 'POST' ]
);
}
}
@ -1524,6 +1607,22 @@ Get UserInfo response
Convert JSON to HashRef
=head2 newAuthorizationCode
Generate new Authorization Code session
=head2 newAccessToken
Generate new Access Token session
=head2 getAuthorizationCode
Get existing Authorization Code session
=head2 getAccessToken
Get existing Access Token session
=head2 getOpenIDConnectSession
Try to recover the OpenID Connect session corresponding to id and return session

View File

@ -17,6 +17,13 @@ has ua => (
sub restCall {
my ( $self, $url, $content ) = @_;
$self->logger->debug("REST: trying to call $url with:");
eval {
foreach ( keys %$content ) {
$self->logger->debug(
" $_: " . ( /password/ ? '****' : $content->{$_} ) );
}
};
my $hreq = HTTP::Request->new( POST => $url );
$hreq->header( 'Content-Type' => 'application/json' );
$hreq->content( to_json($content) );

View File

@ -259,6 +259,13 @@ sub display {
or ( not $req->data->{noerror}
and $req->userData
and %{ $req->userData } )
# Avoid issue 1867
or ( $self->conf->{authentication} eq 'Combination'
and $req->{error} > PE_OK
and $req->{error} != PE_FIRSTACCESS )
# and ( $req->{error} == PE_TOKENEXPIRED or $req->{error} == PE_NOTOKEN )
)
{
$skinfile = 'error';

View File

@ -1,6 +1,9 @@
document.onreadystatechange = () ->
if document.readyState == "complete"
redirect = document.getElementById('redirect').textContent.replace /\s/g, ''
try
redirect = document.getElementById('redirect').textContent.replace /\s/g, ''
catch
redirect = document.getElementById('redirect').innerHTML.replace /\s/g, ''
if redirect
if redirect == 'form'
document.getElementById('form').submit()

View File

@ -3,7 +3,11 @@
document.onreadystatechange = function() {
var redirect;
if (document.readyState === "complete") {
redirect = document.getElementById('redirect').textContent.replace(/\s/g, '');
try {
redirect = document.getElementById('redirect').textContent.replace(/\s/g, '');
} catch (error) {
redirect = document.getElementById('redirect').innerHTML.replace(/\s/g, '');
}
if (redirect) {
if (redirect === 'form') {
return document.getElementById('form').submit();

View File

@ -1,2 +1,2 @@
(function(){document.onreadystatechange=function(){var e;if("complete"===document.readyState)return e=document.getElementById("redirect").textContent.replace(/\s/g,""),e?"form"===e?document.getElementById("form").submit():document.location.href=e:console.log("No redirection !")}}).call(this);
//# sourceMappingURL=lemonldap-ng-portal/site/htdocs/static/common/js/redirect.min.js.map
//# sourceMappingURL=lemonldap-ng-portal/site/htdocs/static/common/js/redirect.min.js.map

View File

@ -1 +1 @@
{"version":3,"sources":["lemonldap-ng-portal/site/htdocs/static/common/js/redirect.js"],"names":["document","onreadystatechange","redirect","readyState","getElementById","textContent","replace","submit","location","href","console","log","call","this"],"mappings":"CACA,WACEA,SAASC,mBAAqB,WAC5B,GAAIC,EACJ,IAA4B,aAAxBF,SAASG,WAEX,MADAD,GAAWF,SAASI,eAAe,YAAYC,YAAYC,QAAQ,MAAO,IACtEJ,EACe,SAAbA,EACKF,SAASI,eAAe,QAAQG,SAEhCP,SAASQ,SAASC,KAAOP,EAG3BQ,QAAQC,IAAI,uBAKxBC,KAAKC","file":"lemonldap-ng-portal/site/htdocs/static/common/js/redirect.min.js"}
{"version":3,"sources":["lemonldap-ng-portal/site/htdocs/static/common/js/redirect.js"],"names":["document","onreadystatechange","redirect","readyState","getElementById","textContent","replace","submit","location","href","console","log","call","this"],"mappings":"CACA,WACEA,SAASC,mBAAqB,WAC5B,GAAIC,EACJ,IAA4B,aAAxBF,SAASG,WAEX,MADAD,GAAWF,SAASI,eAAe,YAAYC,YAAYC,QAAQ,MAAO,IACtEJ,EACe,SAAbA,EACKF,SAASI,eAAe,QAAQG,SAEhCP,SAASQ,SAASC,KAAOP,EAG3BQ,QAAQC,IAAI,uBAKxBC,KAAKC","file":"lemonldap-ng-portal/site/htdocs/static/common/js/redirect.min.js"}

View File

@ -8,11 +8,12 @@ my $res;
my $client = LLNG::Manager::Test->new( {
ini => {
logLevel => 'error',
useSafeJail => 1,
'corsAllow_Origin' => '',
'corsAllow_Methods' => 'POST',
'cspFormAction' => '*'
logLevel => 'error',
useSafeJail => 1,
corsAllow_Origin => '',
corsAllow_Methods => 'POST',
cspFormAction => '*',
customToTrace => 'mail'
}
}
);
@ -37,44 +38,33 @@ ok( $res->[2]->[0] =~ m%<span id="languages"></span>%, ' Language icons found' )
or print STDERR Dumper( $res->[2]->[0] );
count(2);
my %policy = @{ $res->[1] };
# CORS
ok( $res->[1]->[12] eq 'Access-Control-Allow-Origin', ' CORS origin found' )
ok( $policy{'Access-Control-Allow-Origin'} eq '', "CORS origin '' found" )
or print STDERR Dumper( $res->[1] );
ok( $res->[1]->[13] eq '', " CORS origin ''" )
ok( $policy{'Access-Control-Allow-Credentials'} eq 'true',
"CORS credentials 'true' found" )
or print STDERR Dumper( $res->[1] );
ok( $res->[1]->[14] eq 'Access-Control-Allow-Credentials',
' CORS credentials found' )
ok( $policy{'Access-Control-Allow-Headers'} eq '*', "CORS headers '*' found" )
or print STDERR Dumper( $res->[1] );
ok( $res->[1]->[15] eq 'true', " CORS credentials 'true'" )
ok( $policy{'Access-Control-Allow-Methods'} eq 'POST',
"CORS methods 'POST' found" )
or print STDERR Dumper( $res->[1] );
ok( $res->[1]->[16] eq 'Access-Control-Allow-Headers', " CORS headers found" )
ok( $policy{'Access-Control-Expose-Headers'} eq '*',
"CORS expose-headers '*' found" )
or print STDERR Dumper( $res->[1] );
ok( $res->[1]->[17] eq '*', " CORS headers '*'" )
ok( $policy{'Access-Control-Max-Age'} eq '86400', "CORS max-age '86400' found" )
or print STDERR Dumper( $res->[1] );
ok( $res->[1]->[18] eq 'Access-Control-Allow-Methods', " CORS methods found" )
or print STDERR Dumper( $res->[1] );
ok( $res->[1]->[19] eq 'POST', " CORS methods 'POST'" )
or print STDERR Dumper( $res->[1] );
ok( $res->[1]->[20] eq 'Access-Control-Expose-Headers',
" CORS expose-headers found" )
or print STDERR Dumper( $res->[1] );
ok( $res->[1]->[21] eq '*', " CORS expose-headers '*'" )
or print STDERR Dumper( $res->[1] );
ok( $res->[1]->[22] eq 'Access-Control-Max-Age', ' CORS max-age found' )
or print STDERR Dumper( $res->[1] );
ok( $res->[1]->[23] == 86400, ' CORS max-age 86400' )
or print STDERR Dumper( $res->[1] );
count(12);
count(6);
#CSP
ok( $res->[1]->[26] eq 'Content-Security-Policy', ' CSP found' )
or print STDERR Dumper( $res->[1] );
ok(
$res->[1]->[27] =~
$policy{'Content-Security-Policy'} =~
/default-src 'self';img-src 'self' data:;style-src 'self';font-src 'self';connect-src 'self';script-src 'self';form-action \*;frame-ancestors 'none'/,
' CSP headers found'
'CSP header value found'
) or print STDERR Dumper( $res->[1] );
count(2);
count(1);
# Try to authenticate with good password
# --------------------------------------
@ -115,39 +105,36 @@ ok(
);
count(1);
ok( $res->[1]->[14] eq 'Access-Control-Allow-Origin', ' CORS origin found' )
or print STDERR Dumper( $res->[1] );
ok( $res->[1]->[15] eq '', " CORS origin ''" )
or print STDERR Dumper( $res->[1] );
ok( $res->[1]->[16] eq 'Access-Control-Allow-Credentials',
' CORS credentials found' )
or print STDERR Dumper( $res->[1] );
ok( $res->[1]->[17] eq 'true', " CORS credentials 'true'" )
or print STDERR Dumper( $res->[1] );
ok( $res->[1]->[18] eq 'Access-Control-Allow-Headers', " CORS headers found" )
or print STDERR Dumper( $res->[1] );
ok( $res->[1]->[19] eq '*', " CORS headers '*'" )
or print STDERR Dumper( $res->[1] );
ok( $res->[1]->[20] eq 'Access-Control-Allow-Methods', " CORS methods found" )
or print STDERR Dumper( $res->[1] );
ok( $res->[1]->[21] eq 'POST', " CORS methods 'POST'" )
or print STDERR Dumper( $res->[1] );
ok( $res->[1]->[22] eq 'Access-Control-Expose-Headers',
" CORS expose-headers found" )
or print STDERR Dumper( $res->[1] );
ok( $res->[1]->[23] eq '*', " CORS expose-headers '*'" )
or print STDERR Dumper( $res->[1] );
ok( $res->[1]->[24] eq 'Access-Control-Max-Age', ' CORS max-age found' )
or print STDERR Dumper( $res->[1] );
ok( $res->[1]->[25] == 86400, ' CORS max-age 86400' )
or print STDERR Dumper( $res->[1] );
count(12);
%policy = @{ $res->[1] };
# Lm-Remote headers
ok( $policy{'Lm-Remote-User'} eq 'dwho', "Lm-Remote-User found" )
or print STDERR Dumper( $res->[1] );
ok( $policy{'Lm-Remote-Custom'} eq 'dwho@badwolf.org',
"Lm-Remote-Custom found" )
or print STDERR Dumper( $res->[1] );
count(2);
# CORS
ok( $policy{'Access-Control-Allow-Origin'} eq '', "CORS origin '' found" )
or print STDERR Dumper( $res->[1] );
ok( $policy{'Access-Control-Allow-Credentials'} eq 'true',
"CORS credentials 'true' found" )
or print STDERR Dumper( $res->[1] );
ok( $policy{'Access-Control-Allow-Headers'} eq '*', "CORS headers '*' found" )
or print STDERR Dumper( $res->[1] );
ok( $policy{'Access-Control-Allow-Methods'} eq 'POST',
"CORS methods 'POST' found" )
or print STDERR Dumper( $res->[1] );
ok( $policy{'Access-Control-Expose-Headers'} eq '*',
"CORS expose-headers '*' found" )
or print STDERR Dumper( $res->[1] );
ok( $policy{'Access-Control-Max-Age'} eq '86400', "CORS max-age '86400' found" )
or print STDERR Dumper( $res->[1] );
count(6);
# Test logout
$client->logout($id);
#print STDERR Dumper($res);
clean_sessions();
done_testing( count() );

View File

@ -5,6 +5,7 @@ use IO::String;
use LWP::UserAgent;
use LWP::Protocol::PSGI;
use MIME::Base64;
use JSON;
BEGIN {
require 't/test-lib.pm';
@ -54,7 +55,7 @@ my $op = LLNG::Manager::Test->new( {
oidcRPMetaDataOptionsIDTokenSignAlg => "HS512",
oidcRPMetaDataOptionsClientSecret => "rpsecret",
oidcRPMetaDataOptionsUserIDAttr => "",
oidcRPMetaDataOptionsAccessTokenExpiration => 3600,
oidcRPMetaDataOptionsAccessTokenExpiration => 1,
oidcRPMetaDataOptionsBypassConsent => 1,
},
rp2 => {
@ -64,7 +65,7 @@ my $op = LLNG::Manager::Test->new( {
oidcRPMetaDataOptionsIDTokenSignAlg => "HS512",
oidcRPMetaDataOptionsClientSecret => "rp2secret",
oidcRPMetaDataOptionsUserIDAttr => "",
oidcRPMetaDataOptionsAccessTokenExpiration => 3600,
oidcRPMetaDataOptionsAccessTokenExpiration => 1,
oidcRPMetaDataOptionsBypassConsent => 1,
oidcRPMetaDataOptionsRule => '$uid eq "dwho"',
}
@ -173,6 +174,44 @@ count(1);
ok ($res->[0] = 400);
count(1);
# Play code on RP1
$query="grant_type=authorization_code&code=$code&redirect_uri=http%3A%2F%2Frp2.com%2F";
ok(
$res = $op->_post(
"/oauth2/token",
IO::String->new($query),
accept => 'text/html',
length => length($query),
custom => {
HTTP_AUTHORIZATION => "Basic ". encode_base64("rpid:rpsecret"),
},
),
"Post token"
);
count(1);
my $json = from_json($res->[2]->[0]);
my $token = $json->{access_token};
ok($token, 'Access token present');
count(1);
sleep(2);
ok(
$res = $op->_post(
"/oauth2/userinfo",
IO::String->new(""),
accept => 'text/html',
length => 0,
custom => {
HTTP_AUTHORIZATION => "Bearer ". $token,
},
),
"Post userinfo"
);
count(1);
ok($res->[0] == 401, "Access denied with expired token");
count(1);
clean_sessions();
done_testing( count() );

View File

@ -3,7 +3,7 @@ use strict;
use IO::String;
require 't/test-lib.pm';
my $maintests = 19;
my $maintests = 20;
SKIP: {
eval { require Convert::Base32 };
@ -24,7 +24,7 @@ SKIP: {
loginHistoryEnabled => 0,
authentication => 'Combination',
userDB => 'Same',
combination => '[Dm1] or [Dm2]',
combination => '[ssl, Dm1] or [Dm2]',
combModules => {
Dm1 => {
for => 0,
@ -34,6 +34,10 @@ SKIP: {
for => 0,
type => 'Demo',
},
ssl => {
for => 1,
type => 'SSL',
}
},
}
}
@ -161,10 +165,11 @@ SKIP: {
),
'Post code'
);
( $host, $url, $query ) =
expectForm( $res, '#', undef, 'user', 'password', 'token' );
ok( $res->[2]->[0] =~ /<span trmsg="82"><\/span>/, 'Token expired' )
or print STDERR Dumper( $res->[2]->[0] );
ok( $res = $client->_get( '/', accept => 'text/html' ), 'Get Menu', );
my ( $host, $url, $query ) =
expectForm( $res, '#', undef, 'user', 'password', 'token' );
}
count($maintests);