Commit 7e450f55 authored by Xavier Guimard's avatar Xavier Guimard

Merge branch 'maudoux/lemonldap-ng-manage-users-u2f-keys'

parents b20d68e2 b30d3db3
node_modules
e2e-tests/conf/
lemonldap-ng-common/MYMETA.json
lemonldap-ng-common/MYMETA.yml
lemonldap-ng-common/Makefile
lemonldap-ng-common/blib/
lemonldap-ng-common/pm_to_blib
lemonldap-ng-handler/MYMETA.json
lemonldap-ng-handler/MYMETA.yml
lemonldap-ng-handler/Makefile
lemonldap-ng-handler/blib/
lemonldap-ng-handler/pm_to_blib
lemonldap-ng-manager/MYMETA.json
lemonldap-ng-manager/MYMETA.yml
lemonldap-ng-manager/Makefile
lemonldap-ng-manager/blib/
lemonldap-ng-manager/pm_to_blib
lemonldap-ng-portal/MYMETA.json
lemonldap-ng-portal/MYMETA.yml
lemonldap-ng-portal/Makefile
lemonldap-ng-portal/blib/
lemonldap-ng-portal/t/
.gitignore
lemonldap-ng-portal/pm_to_blib
e2e-tests/lemonldap-ng.ini
......@@ -32,6 +32,7 @@ useRedirectOnError = 0
[manager]
enabledModules = conf, sessions, notifications, U2F
protection = manager
staticPrefix = /static
languages = fr, en, vi, ar
......
......@@ -318,7 +318,7 @@ Examples:
$self->addRoute( books => { ':bookId' => 'book' }, ['GET'] );
booId parameter will be stored in $req->params('bookId');
bookId parameter will be stored in $req->params('bookId');
=item to manage http://.../books/127/pages/5 with page(), use:
......
......@@ -33,6 +33,8 @@ sub hAttr {
$_[0]->{hiddenAttributes} || $_[0]->conf->{hiddenAttributes};
}
### SEE LEMONLDAP::NG::COMMON::SESSION FOR AVAILABLE FUNCTIONS
sub delSession {
my ( $self, $req ) = @_;
return $self->sendJSONresponse( $req, { result => 1 } )
......@@ -50,6 +52,31 @@ sub delSession {
return $self->sendJSONresponse( $req, { result => 1 } );
}
sub delU2FKey {
my ( $self, $req ) = @_;
return $self->sendJSONresponse( $req, { result => 1 } )
if ( $self->{demoMode} );
my $mod = $self->getMod($req)
or return $self->sendError( $req, undef, 400 );
my $id = $req->params('sessionId')
or return $self->sendError( $req, 'sessionId is missing', 400 );
# Try to read session
my $session = $self->getApacheSession( $mod, $id )
or return $self->sendError( $req, undef, 400 );
# Delete U2F key attributs and update session
$session->data->{_u2fKeyHandle} = '';
$session->data->{_u2fUserKey} = '';
$session->update( \%{ $session->data } );
Lemonldap::NG::Handler::PSGI::Main->localUnlog( $req, $id );
if ( $session->error ) {
return $self->sendError( $req, $session->error, 200 );
}
return $self->sendJSONresponse( $req, { result => 1 } );
}
sub session {
my ( $self, $req, $id, $skey ) = @_;
my ( %h, $res );
......
......@@ -20,6 +20,7 @@ lib/Lemonldap/NG/Manager/Conf/Tests.pm
lib/Lemonldap/NG/Manager/Conf/Zero.pm
lib/Lemonldap/NG/Manager/Notifications.pm
lib/Lemonldap/NG/Manager/Sessions.pm
lib/Lemonldap/NG/Manager/U2F.pm
Makefile.PL
MANIFEST This list of files
META.yml
......@@ -33,6 +34,7 @@ site/coffee/llApp.coffee
site/coffee/manager.coffee
site/coffee/notifications.coffee
site/coffee/sessions.coffee
site/coffee/u2f.coffee
site/htdocs/manager.fcgi
site/htdocs/manager.psgi
site/htdocs/static/bwr/angular-animate/angular-animate.js
......@@ -146,6 +148,8 @@ site/htdocs/static/js/notifications.js
site/htdocs/static/js/notifications.min.js
site/htdocs/static/js/sessions.js
site/htdocs/static/js/sessions.min.js
site/htdocs/static/js/u2f.js
site/htdocs/static/js/u2f.min.js
site/htdocs/static/languages/ar.json
site/htdocs/static/languages/en.json
site/htdocs/static/languages/fr.json
......@@ -175,6 +179,7 @@ site/templates/notifications.tpl
site/templates/scripts.tpl
site/templates/sessions.tpl
site/templates/tree.tpl
site/templates/u2f.tpl
t/02-HTML-template.t
t/03-HTML-forms.t
t/05-rest-api.t
......
......@@ -89,8 +89,9 @@ sub init {
$self->defaultRoute( $working[0]->defaultRoute );
# Find out more glyphicones at https://www.w3schools.com/icons/bootstrap_icons_glyphicons.asp
my $linksIcons =
{ 'conf' => 'cog', 'sessions' => 'duplicate', 'notifications' => 'bell' };
{ 'conf' => 'cog', 'sessions' => 'duplicate', 'notifications' => 'bell', 'U2F' => 'wrench' };
$self->links( [] );
for ( my $i = 0 ; $i < @links ; $i++ ) {
......
package Lemonldap::NG::Manager::U2F;
use 5.10.0;
use utf8;
use strict;
use Mouse;
use Lemonldap::NG::Common::Session;
use Lemonldap::NG::Common::Conf::Constants;
use Lemonldap::NG::Common::PSGI::Constants;
use Lemonldap::NG::Common::Conf::ReConstants;
use Lemonldap::NG::Common::IPv6;
use feature 'state';
extends 'Lemonldap::NG::Common::Conf::AccessLib',
'Lemonldap::NG::Common::Session::REST';
our $VERSION = '2.0.0';
#############################
# I. INITIALIZATION METHODS #
#############################
use constant defaultRoute => 'u2f.html#/persistent';
sub addRoutes {
my ( $self, $conf ) = @_;
# Remote Procedure Call are defined in Lemonldap::NG::Common::Session::REST
# HTML template
$self->addRoute( 'u2f.html', undef, ['GET'] )
# READ
->addRoute(
u2f => { ':sessionType' => 'sessions' },
['GET']
)
# DELETE U2F KEY ATTRIBUTS
->addRoute(
u2f => { ':sessionType' => { ':sessionId' => 'delU2FKey' } },
['DELETE']
);
# UPDATE U2F KEY ATTRIBUTS
# ->addRoute(
# u2f => { ':sessionType' => { ':sessionId' => { ':updateSession' } },
# ['PUT']
#);
$self->setTypes($conf);
$self->{ipField} ||= 'ipAddr';
$self->{multiValuesSeparator} ||= '; ';
$self->{hiddenAttributes} //= "_password";
}
#######################
# II. DISPLAY METHODS #
#######################
sub u2f {
my ( $self, $req, $session, $skey ) = @_;
# Case 1: only one session is required
if ($session) {
return $self->session( $req, $session, $skey );
}
my $mod = $self->getMod($req)
or return $self->sendError( $req, undef, 400 );
my $params = $req->parameters();
my $type = delete $params->{sessionType};
$type = $type eq 'global' ? 'SSO' : ucfirst($type);
my $res;
# Case 2: list of sessions
my $whatToTrace = Lemonldap::NG::Handler::PSGI::Main->tsv->{whatToTrace};
# 2.1 Get fields to require
my @fields = ( '_httpSessionType', $self->{ipField}, $whatToTrace );
if ( my $groupBy = $params->{groupBy} ) {
$groupBy =~ s/^substr\((\w+)(?:,\d+(?:,\d+)?)?\)$/$1/
or $groupBy =~ s/^net(?:4|6|)\(([\w:]+),\d+(?:,\d+)?\)$/$1/;
$groupBy =~ s/^_whatToTrace$/$whatToTrace/o
or push @fields, $groupBy;
}
elsif ( my $order = $params->{orderBy} ) {
$order =~ s/^net(?:4|6|)\(([\w:]+)\)$/$1/;
$order =~ s/^_whatToTrace$/$whatToTrace/o
or push @fields, split( /, /, $order );
}
else {
push @fields, '_utime';
}
# 2.2 Restrict query if possible: search for filters (any query arg that is
# not a keyword)
my $moduleOptions = $mod->{options};
$moduleOptions->{backend} = $mod->{module};
my %filters = map {
my $s = $_;
$s =~ s/\b_whatToTrace\b/$whatToTrace/o;
/^(?:(?:group|order)By|doubleIp)$/
? ()
: ( $s => $params->{$_} );
} keys %$params;
$filters{_session_kind} = $type;
push @fields, keys(%filters);
{
my %seen;
@fields = grep { !$seen{$_}++ } @fields;
}
# For now, only one argument can be passed to
# Lemonldap::NG::Common::Apache::Session so just the first filter is
# used
my ($firstFilter) = sort {
$filters{$a} =~ m#^[\w:]+/\d+\*?$# ? 1
: $filters{$b} =~ m#^[\w:]+/\d+\*?$# ? -1
: $a eq '_session_kind' ? 1
: $b eq '_session_kind' ? -1
: $a cmp $b
} keys %filters;
# Check if a '*' is required
my $function = 'searchOn';
$function = 'searchOnExpr'
if ( grep { /\*/ and not m#^[\w:]+/\d+\*?$# }
( $filters{$firstFilter} ) );
$self->logger->debug(
"First filter: $firstFilter = $filters{$firstFilter} ($function)");
$res =
Lemonldap::NG::Common::Apache::Session->$function( $moduleOptions,
$firstFilter, $filters{$firstFilter}, @fields );
return $self->sendJSONresponse(
$req,
{
result => 1,
count => 0,
total => 0,
values => []
}
) unless ( $res and %$res );
delete $filters{$firstFilter}
unless ( grep { /\*/ and not m#^[\w:]+/\d+\*?$# }
( $filters{$firstFilter} ) );
foreach my $k ( keys %filters ) {
$self->logger->debug("Removing unless $k =~ /^$filters{$k}\$/");
if ( $filters{$k} =~ m#^([\w:]+)/(\d+)\*?$# ) {
my ( $net, $bits ) = ( $1, $2 );
foreach my $session ( keys %$res ) {
delete $res->{$session}
unless ( net6( $res->{$session}->{$k}, $bits ) eq $net );
}
}
else {
$filters{$k} =~ s/\./\\./g;
$filters{$k} =~ s/\*/\.\*/g;
foreach my $session ( keys %$res ) {
if ( $res->{$session}->{$k} ) {
delete $res->{$session}
unless ( $res->{$session}->{$k} =~ /^$filters{$k}$/ );
}
}
}
}
my $total = ( keys %$res );
# 2.4 Special case doubleIp (users connected from more than 1 IP)
if ( defined $params->{doubleIp} ) {
my %r;
# 2.4.1 Store user IP addresses in %r
foreach my $id ( keys %$res ) {
my $entry = $res->{$id};
next if ( $entry->{_httpSessionType} );
$r{ $entry->{$whatToTrace} }->{ $entry->{ $self->{ipField} } }++;
}
# 2.4.2 Store sessions owned by users that has more than one IP address in $r
my $r;
$total = 0;
foreach my $k ( keys %$res ) {
my @tmp = keys %{ $r{ $res->{$k}->{$whatToTrace} } };
if ( @tmp > 1 ) {
$total += 1;
$res->{$k}->{_sessionId} = $k;
push @{ $r->{ $res->{$k}->{$whatToTrace} } }, $res->{$k};
}
}
# 2.4.3 Store these session in an array. Array elements are :
# {
# uid => whatToTraceFieldValue,
# sessions => [
# { session => <session-id-1>, date => <_utime> },
# { session => <session-id-2>, date => <_utime> },
# ]
# }
$res = [];
foreach my $uid ( sort keys %$r ) {
push @$res, {
value => $uid,
count => scalar( @{ $r->{$uid} } ),
sessions => [
map {
{
session => $_->{_sessionId},
date => $_->{_utime}
}
} @{ $r->{$uid} }
]
};
}
}
# 2.4 Order and group by
# $res will become an array ref here (except for doubleIp, already done below).
# If "groupBy" is asked, elements will be like:
# { uid => 'foo.bar', count => 3 }
elsif ( my $group = $req->params('groupBy') ) {
my $r;
$group =~ s/\b_whatToTrace\b/$whatToTrace/o;
# Substrings
if ( $group =~ /^substr\((\w+)(?:,(\d+)(?:,(\d+))?)?\)$/ ) {
my ( $field, $length, $start ) = ( $1, $2, $3 );
$start ||= 0;
$length = 1 if ( $length < 1 );
foreach my $k ( keys %$res ) {
$r->{ substr $res->{$k}->{$field}, $start, $length }++
if ( $res->{$k}->{$field} );
}
$group = $field;
}
# Subnets IPv4
elsif ( $group =~ /^net4\((\w+),(\d)\)$/ ) {
my $field = $1;
my $nb = $2 - 1;
foreach my $k ( keys %$res ) {
if ( $res->{$k}->{$field} =~ /^((((\d+)\.\d+)\.\d+)\.\d+)$/ ) {
my @d = ( $4, $3, $2, $1 );
$r->{ $d[$nb] }++;
}
}
$group = $field;
}
# Subnets IPv6
elsif ( $group =~ /^net6\(([\w:]+),(\d)\)$/ ) {
my $field = $1;
my $bits = $2;
foreach my $k ( keys %$res ) {
$r->{ net6( $res->{$k}->{$field}, $bits ) . "/$bits" }++
if ( isIPv6( $res->{$k}->{$field} ) );
}
}
# Both IPv4 and IPv6
elsif ( $group =~ /^net\(([\w:]+),(\d+),(\d+)\)$/ ) {
my $field = $1;
my $bits = $2;
my $nb = $3 - 1;
foreach my $k ( keys %$res ) {
if ( isIPv6( $res->{$k}->{$field} ) ) {
$r->{ net6( $res->{$k}->{$field}, $bits ) . "/$bits" }++;
}
elsif ( $res->{$k}->{$field} =~ /^((((\d+)\.\d+)\.\d+)\.\d+)$/ )
{
my @d = ( $4, $3, $2, $1 );
$r->{ $d[$nb] }++;
}
}
}
# Simple field groupBy query
elsif ( $group =~ /^\w+$/ ) {
eval {
foreach my $k ( keys %$res ) {
$r->{ $res->{$k}->{$group} }++;
}
};
return $self->sendError(
$req,
qq{Use of an uninitialized attribute "$group" to group sessions},
400
) if ($@);
}
else {
return $self->sendError( $req, 'Syntax error in groupBy', 400 );
}
# Build result
$res = [
sort {
my @a = ( $a->{value} =~ /^(\d+)(?:\.(\d+))*$/ );
my @b = ( $b->{value} =~ /^(\d+)(?:\.(\d+))*$/ );
( @a and @b )
? ( $a[0] <=> $b[0]
or $a[1] <=> $b[1]
or $a[2] <=> $b[2]
or $a[3] <=> $b[3] )
: $a->{value} cmp $b->{value}
}
map { { value => $_, count => $r->{$_} } } keys %$r
];
}
# Else if "orderBy" is asked, $res elements will be like:
# { uid => 'foo.bar', session => <sessionId> }
elsif ( my $f = $req->params('orderBy') ) {
my @fields = split /,/, $f;
my @r = map {
my $tmp = { session => $_ };
foreach my $f (@fields) {
my $s = $f;
$s =~ s/^net(?:4|6|)\(([\w:]+)\)$/$1/;
$tmp->{$s} = $res->{$_}->{$s};
}
$tmp
} keys %$res;
while ( my $f = pop @fields ) {
if ( $f =~ s/^net4\((\w+)\)$/$1/ ) {
@r = sort { cmpIPv4( $a->{$f}, $b->{$f} ); } @r;
}
elsif ( $f =~ s/^net6\(([:\w]+)\)$/$1/ ) {
@r = sort { expand6( $a->{$f} ) cmp expand6( $b->{$f} ); } @r;
}
elsif ( $f =~ s/^net\(([:\w]+)\)$/$1/ ) {
@r = sort {
my $ip1 = $a->{$f};
my $ip2 = $b->{$f};
isIPv6($ip1)
? (
isIPv6($ip2)
? expand6($ip1) cmp expand6($ip2)
: -1
)
: isIPv6($ip2) ? 1
: cmpIPv4( $ip1, $ip2 );
} @r;
}
else {
@r = sort { $a->{$f} cmp $b->{$f} } @r;
}
}
$res = [@r];
}
# Else, $res elements will be like:
# { session => <sessionId>, date => <timestamp> }
else {
$res = [
sort { $a->{date} <=> $b->{date} }
map { { session => $_, date => $res->{$_}->{_utime} } }
keys %$res
];
}
return $self->sendJSONresponse(
$req,
{
result => 1,
count => scalar(@$res),
total => $total,
values => $res
}
);
}
sub cmpIPv4 {
my @a = split /\./, $_[0];
my @b = split /\./, $_[1];
my $cmp = 0;
F: for ( my $i = 0 ; $i < 4 ; $i++ ) {
if ( $a[$i] != $b[$i] ) {
$cmp = $a[$i] <=> $b[$i];
last F;
}
}
$cmp;
}
1;
###
# Session explorer
###
# Max number of session to display (see overScheme)
max = 25
# Queries to do each type of display: each array item corresponds to the depth
# of opened nodes in the tree
schemes =
_whatToTrace: [
(t,v) ->
"groupBy=substr(#{t},1)"
(t,v) ->
"#{t}=#{v}*&groupBy=#{t}"
(t,v) ->
"#{t}=#{v}"
]
ipAddr: [
(t,v) ->
"groupBy=net(#{t},16,1)"
(t,v) ->
v = v + '.' unless v.match /:/
"#{t}=#{v}*&groupBy=net(#{t},32,2)"
(t,v) ->
v = v + '.' unless v.match /:/
"#{t}=#{v}*&groupBy=net(#{t},48,3)"
(t,v) ->
v = v + '.' unless v.match /:/
"#{t}=#{v}*&groupBy=net(#{t},128,4)"
(t,v) ->
"#{t}=#{v}&groupBy=_whatToTrace"
(t,v,q) ->
q.replace(/\&groupBy.*$/, '') + "&_whatToTrace=#{v}"
]
_startTime: [
(t,v) ->
"groupBy=substr(#{t},8)"
(t,v) ->
"#{t}=#{v}*&groupBy=substr(#{t},10)"
(t,v) ->
"#{t}=#{v}*&groupBy=substr(#{t},11)"
(t,v) ->
"#{t}=#{v}*&groupBy=substr(#{t},12)"
(t,v) ->
"#{t}=#{v}*&groupBy=_whatToTrace"
(t,v,q) ->
console.log t
console.log v
console.log q
q.replace(/\&groupBy.*$/, '') + "&_whatToTrace=#{v}"
]
doubleIp: [
(t,v) ->
t
(t,v) ->
"_whatToTrace=#{v}&groupBy=ipAddr"
(t,v,q) ->
q.replace(/\&groupBy.*$/, '') + "&ipAddr=#{v}"
]
overScheme =
_whatToTrace: (t,v,level,over) ->
if level == 1
"#{t}=#{v}*&groupBy=substr(#{t},#{(level+over+1)})"
else
null
ipAddr: (t,v,level,over) ->
if level > 0 and level < 4
"#{t}=#{v}*&groupBy=net(#{t},#{16*level+4*(over+1)},2)"
else
null
hiddenAttributes = '_password'
# Attributes to group in session display
categories =
dateTitle: ['_utime', '_startTime', '_updateTime', '_lastAuthnUTime', '_lastSeen']
connectionTitle: ['ipAddr', '_timezone', '_url']
authenticationTitle:['_session_id', '_user', '_password', 'authenticationLevel']
modulesTitle: ['_auth', '_userDB', '_passwordDB', '_issuerDB', '_authChoice', '_authMulti', '_userDBMulti']
saml: ['_idp', '_idpConfKey', '_samlToken', '_lassoSessionDump', '_lassoIdentityDump']
groups: ['groups', 'hGroups']
ldap: ['dn']
BrowserID: ['_browserIdAnswer', '_browserIdAnswerRaw']
OpenIDConnect: ['_oidc_id_token', '_oidc_OP', '_oidc_access_token']
# Menu entries