diff --git a/Makefile b/Makefile index 691d6f73c..35a68e6d9 100644 --- a/Makefile +++ b/Makefile @@ -428,6 +428,7 @@ prepare_test_server: ETCDEFAULTDIR=`pwd`/e2e-tests/conf/def #@cp -f e2e-tests/index.* e2e-tests/conf/ @cp -f $(SRCMANAGERDIR)/site/htdocs/manager* e2e-tests/conf/manager + @cp -f $(SRCMANAGERDIR)/site/htdocs/api* e2e-tests/conf/manager @cp -f $(SRCPORTALDIR)/site/htdocs/index* e2e-tests/conf/portal @cp e2e-tests/persistent/5efe8af397fc3577e05b483aca964f1b e2e-tests/conf/persistents @cp e2e-tests/saml-sp.xml e2e-tests/conf/site/saml-sp.xml diff --git a/_example/etc/manager-apache2.4.conf b/_example/etc/manager-apache2.4.conf index 62841e33d..20608ea08 100644 --- a/_example/etc/manager-apache2.4.conf +++ b/_example/etc/manager-apache2.4.conf @@ -99,3 +99,76 @@ # Uncomment this if site if you use SSL only #Header set Strict-Transport-Security "max-age=15768000" + +# API virtual host (manager.__DNSDOMAIN__) + + ServerName api.__DNSDOMAIN__ + LogLevel notice + # See above to set LLNG user id in Apache logs + #CustomLog __APACHELOGDIR__/manager.log llng + #ErrorLog __APACHELOGDIR__/lm_err.log + + # Uncomment this if you are running behind a reverse proxy and want + # LemonLDAP::NG to see the real IP address of the end user + # Adjust the settings to match the IP address of your reverse proxy + # and the header containing the original IP address + # + #RemoteIPHeader X-Forwarded-For + #RemoteIPInternalProxy 127.0.0.1 + + + # FASTCGI CONFIGURATION + # --------------------- + + # 1) URI management + RewriteEngine on + + # For performances, you can delete the previous RewriteRule line after + # puttings html files: simply put the HTML results of differents modules + # (configuration, sessions, notifications) as manager.html, sessions.html, + # notifications.html and uncomment the 2 following lines: + # DirectoryIndex manager.html + # RewriteCond "%{REQUEST_URI}" "!\.html(?:/.*)?$" + + # REST URLs + RewriteCond "%{REQUEST_URI}" "!^/(?:static|doc|lib|javascript|favicon).*" + RewriteRule "^/(.+)$" "/api.fcgi/$1" [PT] + + # 2) FastCGI engine + + # You can choose any FastCGI system. Here is an example using mod_fcgid + # mod_fcgid configuration + FcgidMaxRequestLen 2000000 + + SetHandler fcgid-script + Options +ExecCGI + header unset Lm-Remote-User + + + # If you want to use mod_fastcgi, replace lines below by: + #FastCgiServer __MANAGERSITEDIR__/manager.fcgi + + # GLOBAL CONFIGURATION + # -------------------- + + DocumentRoot __MANAGERSITEDIR__ + + + Require all denied + + + AddOutputFilterByType DEFLATE text/html text/plain text/xml text/javascript text/css + SetOutputFilter DEFLATE + BrowserMatch ^Mozilla/4 gzip-only-text/html + BrowserMatch ^Mozilla/4\.0[678] no-gzip + BrowserMatch \bMSIE !no-gzip !gzip-only-text/html + SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png)$ no-gzip dont-vary + + + Header append Vary User-Agent env=!dont-vary + + + + # Uncomment this if site if you use SSL only + #Header set Strict-Transport-Security "max-age=15768000" + diff --git a/_example/etc/manager-apache2.X.conf b/_example/etc/manager-apache2.X.conf index 614c311f7..a87605fc5 100644 --- a/_example/etc/manager-apache2.X.conf +++ b/_example/etc/manager-apache2.X.conf @@ -118,3 +118,83 @@ # Uncomment this if site if you use SSL only #Header set Strict-Transport-Security "max-age=15768000" + +# API virtual host (manager.__DNSDOMAIN__) + + ServerName api.__DNSDOMAIN__ + LogLevel notice + # See above to set LLNG user id in Apache logs + #CustomLog __APACHELOGDIR__/manager.log llng + #ErrorLog __APACHELOGDIR__/lm_err.log + + # Uncomment this if you are running behind a reverse proxy and want + # LemonLDAP::NG to see the real IP address of the end user + # Adjust the settings to match the IP address of your reverse proxy + # and the header containing the original IP address + # + #RemoteIPHeader X-Forwarded-For + #RemoteIPInternalProxy 127.0.0.1 + + + # FASTCGI CONFIGURATION + # --------------------- + + # 1) URI management + RewriteEngine on + + # For performances, you can delete the previous RewriteRule line after + # puttings html files: simply put the HTML results of differents modules + # (configuration, sessions, notifications) as manager.html, sessions.html, + # notifications.html and uncomment the 2 following lines: + # DirectoryIndex manager.html + # RewriteCond "%{REQUEST_URI}" "!\.html(?:/.*)?$" + + # REST URLs + RewriteCond "%{REQUEST_URI}" "!^/(?:static|doc|lib|javascript|favicon).*" + RewriteRule "^/(.+)$" "/api.fcgi/$1" [PT] + + # 2) FastCGI engine + + # You can choose any FastCGI system. Here is an example using mod_fcgid + # mod_fcgid configuration + FcgidMaxRequestLen 2000000 + + SetHandler fcgid-script + Options +ExecCGI + header unset Lm-Remote-User + + + # If you want to use mod_fastcgi, replace lines below by: + #FastCgiServer __MANAGERSITEDIR__/manager.fcgi + + # GLOBAL CONFIGURATION + # -------------------- + + DocumentRoot __MANAGERSITEDIR__ + + + = 2.3> + Require all denied + + + Order Deny,Allow + Deny from all + + Options +FollowSymLinks + + + AddOutputFilterByType DEFLATE text/html text/plain text/xml text/javascript text/css + SetOutputFilter DEFLATE + BrowserMatch ^Mozilla/4 gzip-only-text/html + BrowserMatch ^Mozilla/4\.0[678] no-gzip + BrowserMatch \bMSIE !no-gzip !gzip-only-text/html + SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png)$ no-gzip dont-vary + + + Header append Vary User-Agent env=!dont-vary + + + + # Uncomment this if site if you use SSL only + #Header set Strict-Transport-Security "max-age=15768000" + diff --git a/_example/etc/manager-apache2.conf b/_example/etc/manager-apache2.conf index 540557cbc..fe8003430 100644 --- a/_example/etc/manager-apache2.conf +++ b/_example/etc/manager-apache2.conf @@ -102,3 +102,77 @@ # Uncomment this if site if you use SSL only #Header set Strict-Transport-Security "max-age=15768000" + +# API virtual host (api.__DNSDOMAIN__) + + ServerName api.__DNSDOMAIN__ + LogLevel notice + # See above to set LLNG user id in Apache logs + #CustomLog __APACHELOGDIR__/manager.log llng + #ErrorLog __APACHELOGDIR__/lm_err.log + + # Uncomment this if you are running behind a reverse proxy and want + # LemonLDAP::NG to see the real IP address of the end user + # Adjust the settings to match the IP address of your reverse proxy + # and the header containing the original IP address + # + #RemoteIPHeader X-Forwarded-For + #RemoteIPInternalProxy 127.0.0.1 + + + # FASTCGI CONFIGURATION + # --------------------- + + # 1) URI management + RewriteEngine on + + # For performances, you can delete the previous RewriteRule line after + # puttings html files: simply put the HTML results of differents modules + # (configuration, sessions, notifications) as manager.html, sessions.html, + # notifications.html and uncomment the 2 following lines: + # DirectoryIndex manager.html + # RewriteCond "%{REQUEST_URI}" "!\.html(?:/.*)?$" + + # REST URLs + RewriteCond "%{REQUEST_URI}" "!^/(?:static|doc|lib|javascript|favicon).*" + RewriteRule "^/(.+)$" "/api.fcgi/$1" [PT] + + # 2) FastCGI engine + + # You can choose any FastCGI system. Here is an example using mod_fcgid + # mod_fcgid configuration + FcgidMaxRequestLen 2000000 + + SetHandler fcgid-script + Options +ExecCGI + header unset Lm-Remote-User + + + # If you want to use mod_fastcgi, replace lines below by: + #FastCgiServer __MANAGERSITEDIR__/manager.fcgi + + # GLOBAL CONFIGURATION + # -------------------- + + DocumentRoot __MANAGERSITEDIR__ + + + Order Deny,Allow + Deny from all + + + AddOutputFilterByType DEFLATE text/html text/plain text/xml text/javascript text/css + SetOutputFilter DEFLATE + BrowserMatch ^Mozilla/4 gzip-only-text/html + BrowserMatch ^Mozilla/4\.0[678] no-gzip + BrowserMatch \bMSIE !no-gzip !gzip-only-text/html + SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png)$ no-gzip dont-vary + + + Header append Vary User-Agent env=!dont-vary + + + + # Uncomment this if site if you use SSL only + #Header set Strict-Transport-Security "max-age=15768000" + diff --git a/lemonldap-ng-common/lib/Lemonldap/NG/Common/Conf/ReConstants.pm b/lemonldap-ng-common/lib/Lemonldap/NG/Common/Conf/ReConstants.pm index 65e14245f..a818ad9d8 100644 --- a/lemonldap-ng-common/lib/Lemonldap/NG/Common/Conf/ReConstants.pm +++ b/lemonldap-ng-common/lib/Lemonldap/NG/Common/Conf/ReConstants.pm @@ -38,7 +38,7 @@ our $authParameters = { casParams => [qw(casAuthnLevel)], choiceParams => [qw(authChoiceParam authChoiceModules authChoiceAuthBasic)], combinationParams => [qw(combination combModules combinationForms)], - customParams => [qw(customAuth customUserDB customPassword customRegister customAddParams)], + customParams => [qw(customAuth customUserDB customPassword customRegister customResetCertByMail customAddParams)], dbiParams => [qw(dbiAuthnLevel dbiExportedVars dbiAuthChain dbiAuthUser dbiAuthPassword dbiUserChain dbiUserUser dbiUserPassword dbiAuthTable dbiUserTable dbiAuthLoginCol dbiAuthPasswordCol dbiPasswordMailCol userPivot dbiAuthPasswordHash dbiDynamicHashEnabled dbiDynamicHashValidSchemes dbiDynamicHashValidSaltedSchemes dbiDynamicHashNewPasswordScheme)], demoParams => [qw(demoExportedVars)], facebookParams => [qw(facebookAuthnLevel facebookExportedVars facebookAppId facebookAppSecret facebookUserField)], diff --git a/lemonldap-ng-common/lib/Lemonldap/NG/Common/PSGI/Cli/Lib.pm b/lemonldap-ng-common/lib/Lemonldap/NG/Common/PSGI/Cli/Lib.pm index d74c12dcb..8918d8899 100644 --- a/lemonldap-ng-common/lib/Lemonldap/NG/Common/PSGI/Cli/Lib.pm +++ b/lemonldap-ng-common/lib/Lemonldap/NG/Common/PSGI/Cli/Lib.pm @@ -92,6 +92,35 @@ sub _put { ); } +sub _patch { + my ( $self, $path, $query, $body, $type, $len ) = @_; + die "$body must be a IO::Handle" + unless ( ref($body) and $body->can('read') ); + return $self->app->( { + 'HTTP_ACCEPT' => 'application/json, text/plain, */*', + 'SCRIPT_NAME' => '', + 'HTTP_ACCEPT_ENCODING' => 'gzip, deflate', + 'SERVER_NAME' => '127.0.0.1', + 'QUERY_STRING' => $query, + 'HTTP_CACHE_CONTROL' => 'max-age=0', + 'HTTP_ACCEPT_LANGUAGE' => 'fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3', + 'PATH_INFO' => $path, + 'REQUEST_METHOD' => 'PATCH', + 'REQUEST_URI' => $path . ( $query ? "?$query" : '' ), + 'SERVER_PORT' => '8002', + 'SERVER_PROTOCOL' => 'HTTP/1.1', + 'HTTP_USER_AGENT' => + 'Mozilla/5.0 (VAX-4000; rv:36.0) Gecko/20350101 Firefox', + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTP_HOST' => '127.0.0.1:8002', + 'psgix.input.buffered' => 1, + 'psgi.input' => $body, + 'CONTENT_LENGTH' => $len // scalar( ( stat $body )[7] ), + 'CONTENT_TYPE' => $type, + } + ); +} + sub _del { my ( $self, $path, $query ) = @_; return $self->app->( { diff --git a/lemonldap-ng-common/lib/Lemonldap/NG/Common/PSGI/Request.pm b/lemonldap-ng-common/lib/Lemonldap/NG/Common/PSGI/Request.pm index 0348406c9..ddc6fcafd 100644 --- a/lemonldap-ng-common/lib/Lemonldap/NG/Common/PSGI/Request.pm +++ b/lemonldap-ng-common/lib/Lemonldap/NG/Common/PSGI/Request.pm @@ -129,18 +129,18 @@ PSGIs =head1 SYNOPSIS package My::PSGI; - + use base Lemonldap::NG::Common::PSGI; - + # See Lemonldap::NG::Common::PSGI ... - + sub handler { my ( $self, $req ) = @_; # Do something and return a PSGI response # NB: $req is a Lemonldap::NG::Common::PSGI::Request object if ( $req->accept eq 'text/plain' ) { ... } - + return [ 200, [ 'Content-Type' => 'text/plain' ], [ 'Body lines' ] ]; } diff --git a/lemonldap-ng-common/lib/Lemonldap/NG/Common/PSGI/Router.pm b/lemonldap-ng-common/lib/Lemonldap/NG/Common/PSGI/Router.pm index c86b183b7..ee8a878c6 100644 --- a/lemonldap-ng-common/lib/Lemonldap/NG/Common/PSGI/Router.pm +++ b/lemonldap-ng-common/lib/Lemonldap/NG/Common/PSGI/Router.pm @@ -12,7 +12,7 @@ extends 'Lemonldap::NG::Common::PSGI'; has 'routes' => ( is => 'rw', isa => 'HashRef', - default => sub { { GET => {}, POST => {}, PUT => {}, DELETE => {} } } + default => sub { { GET => {}, POST => {}, PUT => {}, PATCH => {}, DELETE => {} } } ); has 'defaultRoute' => ( is => 'rw', default => 'index.html' ); @@ -20,7 +20,7 @@ has 'defaultRoute' => ( is => 'rw', default => 'index.html' ); sub addRoute { my ( $self, $word, $dest, $methods, $transform ) = (@_); - $methods ||= [qw(GET POST PUT DELETE)]; + $methods ||= [qw(GET POST PUT PATCH DELETE)]; foreach my $method (@$methods) { $self->logger->debug("Add $method route:"); $self->genRoute( $self->routes->{$method}, $word, $dest, $transform ); diff --git a/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/Main/Reload.pm b/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/Main/Reload.pm index 6326af356..e797973cc 100644 --- a/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/Main/Reload.pm +++ b/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/Main/Reload.pm @@ -214,7 +214,7 @@ sub defaultValuesInit { # Override with vhost options if ( $conf->{vhostOptions} ) { my $name = 'vhost' . ucfirst($opt); - foreach my $vhost ( keys %{ $conf->{vhostOptions} } ) { + foreach my $vhost ( sort keys %{ $conf->{vhostOptions} } ) { $conf->{vhostOptions}->{$vhost} ||= {}; my $val = $conf->{vhostOptions}->{$vhost}->{$name}; @@ -228,7 +228,7 @@ sub defaultValuesInit { } } if ( $conf->{vhostOptions} ) { - foreach my $vhost ( keys %{ $conf->{vhostOptions} } ) { + foreach my $vhost ( sort keys %{ $conf->{vhostOptions} } ) { $class->tsv->{type}->{$vhost} = $conf->{vhostOptions}->{$vhost}->{vhostType}; $class->tsv->{authnLevel}->{$vhost} = @@ -326,7 +326,7 @@ sub locationRulesInit { ## @imethod protected void sessionStorageInit(hashRef args) # Initialize the Apache::Session::* module choosed to share user's variables -# and the Cache::Cache module choosed to cache sessions +# and the Cache::Cache module chosen to cache sessions # @param $args reference to the configuration hash sub sessionStorageInit { my ( $class, $conf ) = @_; diff --git a/lemonldap-ng-manager/MANIFEST b/lemonldap-ng-manager/MANIFEST index ef4332376..fd9e899ce 100644 --- a/lemonldap-ng-manager/MANIFEST +++ b/lemonldap-ng-manager/MANIFEST @@ -7,6 +7,11 @@ eg/manager.psgi KINEMATIC.md lib/Lemonldap/NG/Manager.pm lib/Lemonldap/NG/Manager/2ndFA.pm +lib/Lemonldap/NG/Manager/Api.pm +lib/Lemonldap/NG/Manager/Api/2F.pm +lib/Lemonldap/NG/Manager/Api/Common.pm +lib/Lemonldap/NG/Manager/Api/Providers/OidcRp.pm +lib/Lemonldap/NG/Manager/Api/Providers/SamlSp.pm lib/Lemonldap/NG/Manager/Attributes.pm lib/Lemonldap/NG/Manager/Build.pm lib/Lemonldap/NG/Manager/Build/Attributes.pm @@ -40,6 +45,7 @@ site/coffee/notifications.coffee site/coffee/sessions.coffee site/coffee/viewDiff.coffee site/coffee/viewer.coffee +site/htdocs/api.fcgi site/htdocs/manager.fcgi site/htdocs/manager.psgi site/htdocs/static/bwr/angular-animate/angular-animate.js @@ -215,8 +221,10 @@ site/templates/viewDiff.tpl site/templates/viewer.tpl t/02-HTML-template.t t/03-HTML-forms.t +t/04-2F-api.t +t/04-providers-api.t t/05-rest-api.t -t/06-rest-api.t +t/06-rest-api-RSA.t t/07-utf8.t t/10-save-unchanged-conf.t t/11-save-appCat-changed-conf.t diff --git a/lemonldap-ng-manager/lib/Lemonldap/NG/Manager.pm b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager.pm index c7833a050..28001351b 100644 --- a/lemonldap-ng-manager/lib/Lemonldap/NG/Manager.pm +++ b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager.pm @@ -52,7 +52,7 @@ sub init { return 0; } - $self->{enabledModules} ||= "conf, sessions, notifications, 2ndFA"; + $self->{enabledModules} ||= "conf, sessions, notifications, 2ndFA, api"; my @links; my @enabledModules = map { push @links, $_; "Lemonldap::NG::Manager::" . ucfirst($_) } diff --git a/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Api.pm b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Api.pm new file mode 100644 index 000000000..7f8dd4312 --- /dev/null +++ b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Api.pm @@ -0,0 +1,153 @@ +# This module implements all the methods that responds to '/api/*' requests +package Lemonldap::NG::Manager::Api; + +use 5.10.0; +use utf8; +use Mouse; + +extends 'Lemonldap::NG::Common::Conf::RESTServer', + 'Lemonldap::NG::Common::Session::REST'; + +use Lemonldap::NG::Manager::Api::2F; +use Lemonldap::NG::Manager::Api::Providers::OidcRp; +use Lemonldap::NG::Manager::Api::Providers::SamlSp; + +our $VERSION = '2.0.7'; + +############################# +# I. INITIALIZATION METHODS # +############################# + +use constant defaultRoute => 'api.html'; + +sub addRoutes { + my ( $self, $conf ) = @_; + + # HTML template + $self->addRoute( 'api.html', undef, ['GET'] ) + + ->addRoute( + api => { + v1 => { + providers => { + oidc => { + rp => { + findByConfKey => { + ':uPattern' => 'findOidcRpByConfKey' + }, + findByClientId => { + ':uClientId' => 'findOidcRpByClientId' + }, + ':confKey' => 'getOidcRpByConfKey' + }, + }, + saml => { + sp => { + findByConfKey => { + ':uPattern' => 'findSamlSpByConfKey' + }, + findByEntityId => { + ':uEntityId' => 'findSamlSpByEntityId' + }, + ':confKey' => 'getSamlSpByConfKey' + }, + }, + }, + secondFactor => { + ':uid' => { + id => { + ':id' => 'getSecondFactorsById' + }, + type => { + ':type' => 'getSecondFactorsByType' + }, + '*' => 'getSecondFactors' + }, + }, + }, + }, + ['GET'] + ) + + ->addRoute( + api => { + v1 => { + providers => { + oidc => { + rp => 'addOidcRp' + }, + saml => { + sp => 'addSamlSp' + }, + }, + }, + }, + ['POST'] + ) + + ->addRoute( + api => { + v1 => { + providers => { + oidc => { + rp => { ':confKey' => 'replaceOidcRp' } + }, + saml => { + sp => { ':confKey' => 'replaceSamlSp' } + }, + }, + }, + }, + ['PUT'] + ) + + ->addRoute( + api => { + v1 => { + providers => { + oidc => { + rp => { ':confKey' => 'updateOidcRp' } + }, + saml => { + sp => { ':confKey' => 'updateSamlSp' } + }, + }, + }, + }, + ['PATCH'] + ) + + ->addRoute( + api => { + v1 => { + providers => { + oidc => { + rp => { ':confKey' => 'deleteOidcRp' } + }, + saml => { + sp => { ':confKey' => 'deleteSamlSp' } + }, + }, + secondFactor => { + ':uid' => { + id => { + ':id' => 'deleteSecondFactorsById' + }, + type => { + ':type' => 'deleteSecondFactorsByType' + }, + '*' => 'deleteSecondFactors' + }, + }, + }, + }, + ['DELETE'] + ); + + $self->setTypes($conf); + $self->{multiValuesSeparator} ||= '; '; + $self->{hiddenAttributes} //= "_password"; + $self->{TOTPCheck} = $self->{U2FCheck} = $self->{UBKCheck} = '1'; +} + +1; diff --git a/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Api/2F.pm b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Api/2F.pm new file mode 100644 index 000000000..c9dfd5690 --- /dev/null +++ b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Api/2F.pm @@ -0,0 +1,345 @@ +package Lemonldap::NG::Manager::Api::2F; + +our $VERSION = '2.0.8'; + +package Lemonldap::NG::Manager::Api; + +use 5.10.0; +use utf8; +use Mouse; +use JSON; +use MIME::Base64; + +use Lemonldap::NG::Common::Session; + +sub getSecondFactors { + my ( $self, $req ) = @_; + my ( $uid, $res ); + + $uid = $req->params('uid') + or return $self->sendError( $req, 'uid is missing', 400 ); + + $self->logger->debug("[API] 2F for $uid requested"); + + $res = $self->_get2F($uid); + + return $self->sendError( $req, $res->{msg}, $res->{code} ) + unless ( $res->{res} eq 'ok' ); + + return $self->sendJSONresponse( $req, $res->{secondFactors} ); +} + +sub getSecondFactorsByType { + my ( $self, $req ) = @_; + my ( $uid, $type, $res ); + + $uid = $req->params('uid') + or return $self->sendError( $req, 'Uid is missing', 400 ); + + $type = $req->params('type') + or return $self->sendError( $req, 'Type is missing', 400 ); + + $self->logger->debug("[API] 2F for $uid with type $type requested"); + + $res = $self->_get2F( $uid, uc $type ); + + return $self->sendError( $req, $res->{msg}, $res->{code} ) + unless ( $res->{res} eq 'ok' ); + + return $self->sendJSONresponse( $req, $res->{secondFactors} ); +} + +sub getSecondFactorsById { + my ( $self, $req ) = @_; + my ( $uid, $id, $res ); + + $uid = $req->params('uid') + or return $self->sendError( $req, 'uid is missing', 400 ); + + $id = $req->params('id') + or return $self->sendError( $req, 'id is missing', 400 ); + + $self->logger->debug("[API] 2F for $uid with id $id requested"); + + $res = $self->_get2F( $uid, undef, $id ); + + return $self->sendError( $req, $res->{msg}, $res->{code} ) + unless ( $res->{res} eq 'ok' ); + + return $self->sendError( $req, "2F id '$id' not found for user '$uid'", + 404 ) + unless ( scalar @{ $res->{secondFactors} } > 0 ); + + return $self->sendJSONresponse( $req, @{ $res->{secondFactors} }[0] ); +} + +sub deleteSecondFactors { + my ( $self, $req ) = @_; + my ( $uid, $res ); + + $uid = $req->params('uid') + or return $self->sendError( $req, 'uid is missing', 400 ); + + $self->logger->debug("[API] Delete all 2F for $uid requested"); + + $res = $self->_delete2F($uid); + + return $self->sendError( $req, $res->{msg}, $res->{code} ) + unless ( $res->{res} eq 'ok' ); + + return $self->sendJSONresponse( $req, { message => $res->{msg} } ); +} + +sub deleteSecondFactorsById { + my ( $self, $req ) = @_; + my ( $uid, $id, $res ); + + $uid = $req->params('uid') + or return $self->sendError( $req, 'uid is missing', 400 ); + + $id = $req->params('id') + or return $self->sendError( $req, 'id is missing', 400 ); + + $self->logger->debug("[API] Delete 2F for $uid with id $id requested"); + + $res = $self->_delete2F( $uid, undef, $id ); + + return $self->sendError( $req, $res->{msg}, $res->{code} ) + unless ( $res->{res} eq 'ok' ); + + return $self->sendError( $req, "2F id '$id' not found for user '$uid'", + 404 ) + unless ( $res->{removed} > 0 ); + + return $self->sendJSONresponse( $req, { message => $res->{msg} } ); +} + +sub deleteSecondFactorsByType { + my ( $self, $req ) = @_; + my ( $uid, $type, $res ); + + $uid = $req->params('uid') + or return $self->sendError( $req, 'uid is missing', 400 ); + + $type = $req->params('type') + or return $self->sendError( $req, 'type is missing', 400 ); + + $self->logger->debug( + "[API] Delete all 2F for $uid with type $type requested"); + + $res = $self->_delete2F( $uid, uc $type ); + + return $self->sendError( $req, $res->{msg}, $res->{code} ) + unless ( $res->{res} eq 'ok' ); + + return $self->sendJSONresponse( $req, { message => $res->{msg} } ); +} + +sub _get2F { + my ( $self, $uid, $type, $id ) = @_; + my ( $res, $psessions, @secondFactors ); + + if ( defined $type ) { + $res = $self->_checkType($type); + return $res if ( $res->{res} ne 'ok' ); + } + + $psessions = $self->_getSessions2F( $self->_getPersistentMod, 'Persistent', + '_session_uid', $uid ); + + foreach ( keys %$psessions ) { + my $devices = + from_json( $psessions->{$_}->{_2fDevices}, { allow_nonref => 1 } ); + foreach my $device ( @{$devices} ) { + $self->logger->debug( +"Check device [epoch=$device->{epoch}, type=$device->{type}, name=$device->{name}]" + ); + push @secondFactors, + { + id => $self->_genId2F($device), + type => $device->{type}, + name => $device->{name} + } + unless ( ( defined $type and $type ne $device->{type} ) + or ( defined $id and $id ne $self->_genId2F($device) ) ); + } + } + $self->logger->debug( + "Found " . scalar @secondFactors . " 2F devices for uid $uid." ); + return { res => 'ok', secondFactors => [@secondFactors] }; +} + +sub _genId2F { + my ( $self, $device ) = @_; + return encode_base64( "$device->{epoch}::$device->{type}::$device->{name}", + "" ); +} + +sub _getPersistentMod { + my ($self) = @_; + my $mod = $self->sessionTypes->{persistent}; + $mod->{options}->{backend} = $mod->{module}; + return $mod; +} + +sub _getSSOMod { + my ($self) = @_; + my $mod = $self->sessionTypes->{global}; + $mod->{options}->{backend} = $mod->{module}; + return $mod; +} + +sub _getSessions2F { + my ( $self, $mod, $kind, $key, $uid ) = @_; + $self->logger->debug("Looking for sessions for uid $uid ..."); + my $sessions = + Lemonldap::NG::Common::Apache::Session->searchOn( $mod->{options}, $key, + $uid, + ( '_session_kind', '_session_uid', '_session_id', '_2fDevices' ) ); + foreach ( keys %$sessions ) { + delete $sessions->{$_} + unless ( $sessions->{$_}->{_session_kind} eq $kind ); + } + $self->logger->debug( "Found " + . scalar( keys %$sessions ) + . " $kind sessions for uid $uid." ); + + return $sessions; +} + +sub _getSession2F { + my ( $self, $sessionId, $mod ) = @_; + $self->logger->debug("Looking for session with sessionId $sessionId ..."); + my $session = $self->getApacheSession( $mod, $sessionId ); + $self->logger->debug( + defined $session + ? "Session $sessionId found." + : " No session found for sessionId $sessionId" + ); + + return $session; +} + +sub _delete2FFromSessions { + my ( $self, $uid, $type, $id, $mod, $kind, $key ) = @_; + my ( $sessions, $session, $devices, @keep, $removed, + $total, $module, $localStorage ); + $sessions = $self->_getSessions2F( $mod, $kind, $key, $uid ); + foreach ( keys %$sessions ) { + + $session = $self->_getSession2F( $_, $mod ) + or return { res => 'ko', code => 500, msg => $@ }; + + $self->logger->debug( + "Looking for 2F Device(s) attached to sessionId $_"); + + if ( $session->data->{_2fDevices} ) { + $devices = + from_json( $session->data->{_2fDevices}, { allow_nonref => 1 } ); + $total = scalar @$devices; + + $self->logger->debug( + "Found $total 2F devices attached to sessionId $_"); + + @keep = (); + while (@$devices) { + my $element = shift @$devices; + if ( + ( defined $type or defined $id ) + and ( ( defined $type and $type ne $element->{type} ) + or + ( defined $id and $id ne $self->_genId2F($element) ) ) + ) + { + push @keep, $element; + } + else { + $removed->{ $self->_genId2F($element) } = "removed"; + } + } + if ( ( $total - scalar @keep ) > 0 ) { + + # Update session + $self->logger->debug( "Removing " + . ( $total - scalar @keep ) + . " 2F device(s) attached to sessionId $_ ..." ); + $session->data->{_2fDevices} = to_json( \@keep ); + $session->update( $session->data ); + + # Delete from local cache + if ( $session->{options}->{localStorage} ) { + $module = $session->{options}->{localStorage}; + eval "use $module;"; + $localStorage = + $module->new( + $session->{options}->{localStorageOptions} ); + if ( $localStorage->get($_) ) { + $self->logger->debug( + "Delete local cache for session $_"); + $localStorage->remove($_); + } + } + } + else { + $self->logger->debug( +"No matching 2F devices attached to sessionId $_ were selected for removal." + ); + } + } + else { + $self->logger->debug( + "No 2F devices attached to sessionId $_ were found."); + } + } + + return { res => 'ok', removed => $removed }; +} + +sub _delete2F { + my ( $self, $uid, $type, $id ) = @_; + my ( $res, $removed, $count ); + if ( defined $type ) { + $res = $self->_checkType($type); + return $res if ( $res->{res} ne 'ok' ); + } + + $res = + $self->_delete2FFromSessions( $uid, $type, $id, $self->_getPersistentMod, + 'Persistent', '_session_uid' ); + return $res if ( $res->{res} ne 'ok' ); + $removed = $res->{removed} || {}; + + $res = + $self->_delete2FFromSessions( $uid, $type, $id, $self->_getSSOMod, 'SSO', + 'uid' ); + return $res if ( $res->{res} ne 'ok' ); + $res->{removed} ||= {}; + + # merge results + $removed = { %$removed, %{ $res->{removed} } }; + $count = scalar( keys %$removed ); + + return { + res => 'ok', + removed => $count, + msg => $count > 0 + ? "Successful operation: " . $count . " 2F were removed" + : "No operation performed" + }; +} + +sub _checkType { + my ( $self, $type ) = @_; + + return { + res => "ko", + code => 405, + msg => +"Invalid input: Type \"$type\" does not exist. Allowed values for type are: \"U2F\", \"TOTP\" or \"UBK\"" + } + unless ( $type =~ /\b(?:U2F|TOTP|UBK)\b/ ); + + return { res => "ok" }; +} + +1; diff --git a/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Api/Common.pm b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Api/Common.pm new file mode 100644 index 000000000..156945bc4 --- /dev/null +++ b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Api/Common.pm @@ -0,0 +1,79 @@ +package Lemonldap::NG::Manager::Api::Common; + +our $VERSION = '2.0.8'; + +package Lemonldap::NG::Manager::Api; + +use Lemonldap::NG::Manager::Build::Attributes; +use Lemonldap::NG::Manager::Build::CTrees; + +# use Scalar::Util 'weaken'; ? + +sub _isSimpleKeyValueHash { + my ( $self, $hash ) = @_; + return 0 if ( ref($hash) ne "HASH" ); + + foreach ( keys %$hash ) { + return 0 if ( ref( $hash->{$_} ) ne '' || ref($_) ne '' ); + } + + return 1; +} + +sub _setDefaultValues { + my ( $self, $attrs, $rootNode ) = @_; + my @allAttrs = $self->_listAttributes($rootNode); + my $defaultAttrs = Lemonldap::NG::Manager::Build::Attributes::attributes(); + + foreach $attr (@allAttrs) { + unless ( defined $attrs->{$attr} ) { + $attrs->{$attr} = $defaultAttrs->{$attr}->{default} + if ( defined $defaultAttrs->{$attr} + && defined $defaultAttrs->{$attr}->{default} ); + } + } + + return $attrs; +} + +sub _hasAllowedAttributes { + my ( $self, $attributes, $rootNode ) = @_; + my @allowedAttributes = $self->_listAttributes($rootNode); + + foreach $attribute ( keys %{$attributes} ) { + if ( length( ref($attribute) ) ) { + return { + res => "ko", + msg => "Invalid input: Attribute $attribute is not a string." + }; + } + unless ( grep { /^$attribute$/ } @allowedAttributes ) { + return { + res => "ko", + msg => "Invalid input: Attribute $attribute does not exist." + }; + } + } + + return { res => "ok" }; +} + +sub _listAttributes { + my ( $self, $rootNode ) = @_; + my $mainTree = Lemonldap::NG::Manager::Build::CTrees::cTrees(); + my $rootNodes = [ grep { ref($_) eq "HASH" } @{ $mainTree->{$rootNode} } ]; + my @attributes = map { $self->_listNodeAttributes($_) } @$rootNodes; + + return @attributes; +} + +sub _listNodeAttributes { + my ( $self, $node ) = @_; + my @attributes = + map { ref($_) eq "HASH" ? $self->_listNodeAttributes($_) : $_ } + @{ $node->{nodes} }; + + return @attributes; +} + +1; diff --git a/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Api/Providers/OidcRp.pm b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Api/Providers/OidcRp.pm new file mode 100644 index 000000000..2366d32cb --- /dev/null +++ b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Api/Providers/OidcRp.pm @@ -0,0 +1,356 @@ +package Lemonldap::NG::Manager::Api::Providers::OidcRp; + +our $VERSION = '2.0.8'; + +package Lemonldap::NG::Manager::Api; + +use 5.10.0; +use utf8; +use Mouse; + +extends 'Lemonldap::NG::Manager::Api::Common'; + +sub getOidcRpByConfKey { + my ( $self, $req ) = @_; + + my $confKey = $req->params('confKey') + or return $self->sendError( $req, 'confKey is missing', 400 ); + + $self->logger->debug("[API] OIDC RP $confKey configuration requested"); + + # Get latest configuration + my $conf = $self->_confAcc->getConf; + + my $oidcRp = $self->_getOidcRpByConfKey( $conf, $confKey ); + + # Return 404 if not found + return $self->sendError( $req, + "OIDC relying party '$confKey' not found", 404 ) + unless ( defined $oidcRp ); + + return $self->sendJSONresponse( $req, $oidcRp ); +} + +sub findOidcRpByConfKey { + my ( $self, $req ) = @_; + + my $pattern = ( + defined $req->params('uPattern') + ? $req->params('uPattern') + : ( defined $req->params('pattern') ? $req->params('pattern') : undef ) + ); + + return $self->sendError( $req, 'Invalid input: pattern is missing', 405 ) + unless ( defined $pattern ); + + $self->logger->debug( + "[API] Find OIDC RPs by confKey regexp $pattern requested"); + + # Get latest configuration + my $conf = $self->_confAcc->getConf; + + my @oidcRps = + map { $_ =~ $pattern ? $self->_getOidcRpByConfKey( $conf, $_ ) : () } + keys %{ $conf->{oidcRPMetaDataOptions} }; + + return $self->sendJSONresponse( $req, [@oidcRps] ); +} + +sub findOidcRpByClientId { + my ( $self, $req ) = @_; + + my $clientId = ( + defined $req->params('uClientId') ? $req->params('uClientId') + : ( + defined $req->params('clientId') ? $req->params('clientId') + : undef + ) + ); + + return $self->sendError( $req, 'Invalid input: clientId is missing', 405 ) + unless ( defined $clientId ); + + $self->logger->debug("[API] Find OIDC RPs by clientId $clientId requested"); + + # Get latest configuration + my $conf = $self->_confAcc->getConf; + + my $oidcRp = $self->_getOidcRpByClientId( $conf, $clientId ); + return $self->sendError( $req, + "OIDC relying party with clientId '$clientId' not found", 404 ) + unless ( defined $oidcRp ); + + return $self->sendJSONresponse( $req, $oidcRp ); +} + +sub addOidcRp { + my ( $self, $req ) = @_; + my $add = $req->jsonBodyToObj; + + return $self->sendError( $req, "Invalid input: " . $req->error, 405 ) + unless ($add); + + return $self->sendError( $req, 'Invalid input: confKey is missing', 405 ) + unless ( defined $add->{confKey} ); + + return $self->sendError( $req, 'Invalid input: clientId is missing', 405 ) + unless ( defined $add->{clientId} ); + + $self->logger->debug( +"[API] Add OIDC RP with confKey $add->{confKey} and clientId $add->{clientId} requested" + ); + + # Get latest configuration + my $conf = $self->_confAcc->getConf( { noCache => 1 } ); + + return $self->sendError( + $req, + "Invalid input: An OIDC RP with confKey $add->{confKey} already exists", + 405 + ) if ( defined $self->_getOidcRpByConfKey( $conf, $add->{confKey} ) ); + + return $self->sendError( + $req, +"Invalid input: An OIDC RP with clientId $add->{clientId} already exists", + 405 + ) if ( defined $self->_getOidcRpByClientId( $conf, $add->{clientId} ) ); + + $add->{options} = {} unless ( defined $add->{options} ); + $add->{options}->{oidcRPMetaDataOptionsClientID} = $add->{clientId}; + + my $res = $self->_pushOidcRp( $conf, $add->{confKey}, $add, 1 ); + + return $self->sendError( $req, $res->{msg}, 405 ) + unless ( $res->{res} eq 'ok' ); + + return $self->sendJSONresponse( $req, + { message => "Successful operation" } ); +} + +sub updateOidcRp { + my ( $self, $req ) = @_; + my $confKey = $req->params('confKey') + or return $self->sendError( $req, 'confKey is missing', 400 ); + + my $update = $req->jsonBodyToObj; + + return $self->sendError( $req, "Invalid input: " . $req->error, 405 ) + unless ($update); + + $self->logger->debug( + "[API] OIDC RP $confKey configuration update requested"); + + # Get latest configuration + my $conf = $self->_confAcc->getConf( { noCache => 1 } ); + + my $current = $self->_getOidcRpByConfKey( $conf, $confKey ); + + # Return 404 if not found + + return $self->sendError( $req, + "OIDC relying party '$confKey' not found", 404 ) + unless ( defined $current ); + + # check if new clientID exists already + my $res = $self->_isNewOidcRpClientIdUnique( $conf, $confKey, $update ); + + return $self->sendError( $req, $res->{msg}, 405 ) + unless ( $res->{res} eq 'ok' ); + + $res = $self->_pushOidcRp( $conf, $confKey, $update, 0 ); + + return $self->sendError( $req, $res->{msg}, 405 ) + unless ( $res->{res} eq 'ok' ); + + return $self->sendJSONresponse( $req, + { message => "Successful operation" } ); +} + +sub replaceOidcRp { + my ( $self, $req ) = @_; + my $confKey = $req->params('confKey') + or return $self->sendError( $req, 'confKey is missing', 400 ); + + my $replace = $req->jsonBodyToObj; + + return $self->sendError( $req, "Invalid input: " . $req->error, 405 ) + unless ($replace); + return $self->sendError( $req, 'Invalid input: clientId is missing', 405 ) + unless ( defined $replace->{clientId} ); + + $self->logger->debug( + "[API] OIDC RP $confKey configuration replace requested"); + + # Get latest configuration + my $conf = $self->_confAcc->getConf( { noCache => 1 } ); + + # Return 404 if not found + + return $self->sendError( $req, + "OIDC relying party '$confKey' not found", 404 ) + unless ( defined $self->_getOidcRpByConfKey( $conf, $confKey ) ); + + # check if new clientID exists already + my $res = $self->_isNewOidcRpClientIdUnique( $conf, $confKey, $replace ); + return $self->sendError( $req, $res->{msg}, 405 ) + unless ( $res->{res} eq 'ok' ); + + $res = $self->_pushOidcRp( $conf, $confKey, $replace, 1 ); + return $self->sendError( $req, $res->{msg}, 405 ) + unless ( $res->{res} eq 'ok' ); + + return $self->sendJSONresponse( $req, + { message => "Successful operation" } ); +} + +sub deleteOidcRp { + my ( $self, $req ) = @_; + my $confKey = $req->params('confKey') + or return $self->sendError( $req, 'confKey is missing', 400 ); + + # Get latest configuration + my $conf = $self->_confAcc->getConf( { noCache => 1 } ); + + my $delete = $self->_getOidcRpByConfKey( $conf, $confKey ); + + # Return 404 if not found + + return $self->sendError( $req, + "OIDC relying party '$confKey' not found", 404 ) + unless ( defined $delete ); + + delete $conf->{oidcRPMetaDataOptions}->{$confKey}; + delete $conf->{oidcRPMetaDataExportedVars}->{$confKey}; + delete $conf->{oidcRPMetaDataOptionsExtraClaims}->{$confKey}; + + # Save configuration + $self->_confAcc->saveConf($conf); + + return $self->sendJSONresponse( $req, + { message => "Successful operation" } ); +} + +sub _getOidcRpByConfKey { + my ( $self, $conf, $confKey ) = @_; + + # Check if confKey is defined + return undef unless ( defined $conf->{oidcRPMetaDataOptions}->{$confKey} ); + + # Get Client ID + my $clientId = $conf->{oidcRPMetaDataOptions}->{$confKey} + ->{oidcRPMetaDataOptionsClientID}; + + # Get exported vars + my $exportedVars = $conf->{oidcRPMetaDataExportedVars}->{$confKey}; + + # Get extra claim + my $extraClaim = $conf->{oidcRPMetaDataOptionsExtraClaims}->{$confKey}; + + # Get options + my $options = $conf->{oidcRPMetaDataOptions}->{$confKey}; + + return { + confKey => $confKey, + clientId => $clientId, + exportedVars => $exportedVars, + extraClaim => $extraClaim, + options => $options + }; +} + +sub _getOidcRpByClientId { + my ( $self, $conf, $clientId ) = @_; + + foreach ( keys %{ $conf->{oidcRPMetaDataOptions} } ) { + return $self->_getOidcRpByConfKey( $conf, $_ ) + if ( $conf->{oidcRPMetaDataOptions}->{$_} + ->{oidcRPMetaDataOptionsClientID} eq $clientId ); + } + + return undef; +} + +sub _isNewOidcRpClientIdUnique { + my ( $self, $conf, $confKey, $oidcRp ) = @_; + my $curClientId = $self->_getOidcRpByConfKey( $conf, $confKey )->{clientId}; + my $newClientId = + $oidcRp->{clientId} + || $oidcRp->{options}->{oidcRPMetaDataOptionsClientID} + || ""; + if ( $newClientId ne '' && $newClientId ne $curClientId ) { + return { + res => 'ko', + msg => +"An OIDC relying party with clientId '$newClientId' already exists" + } + if ( defined $self->_getOidcRpByClientId( $conf, $newClientId ) ); + } + + return { res => 'ok' }; +} + +sub _pushOidcRp { + my ( $self, $conf, $confKey, $push, $replace ) = @_; + + if ($replace) { + $conf->{oidcRPMetaDataOptions}->{$confKey} = {}; + $conf->{oidcRPMetaDataExportedVars}->{$confKey} = {}; + $conf->{oidcRPMetaDataOptionsExtraClaims}->{$confKey} = {}; + $push->{options} = + $self->_setDefaultValues( $push->{options}, 'oidcRPMetaDataNode' ); + } + + if ( defined $push->{options} ) { + my $res = $self->_hasAllowedAttributes( $push->{options}, + 'oidcRPMetaDataNode' ); + return $res unless ( $res->{res} eq 'ok' ); + + foreach ( keys %{ $push->{options} } ) { + $conf->{oidcRPMetaDataOptions}->{$confKey}->{$_} = + $push->{options}->{$_}; + } + } + + $conf->{oidcRPMetaDataOptions}->{$confKey}->{oidcRPMetaDataOptionsClientID} + = $push->{clientId} + if ( defined $push->{clientId} ); + + if ( defined $push->{exportedVars} ) { + if ( $self->_isSimpleKeyValueHash( $push->{exportedVars} ) ) { + foreach ( keys %{ $push->{exportedVars} } ) { + $conf->{oidcRPMetaDataExportedVars}->{$confKey}->{$_} = + $push->{exportedVars}->{$_}; + } + } + else { + return { + res => 'ko', + msg => +"Invalid input: exportedVars is not a hash object with \"key\":\"value\" attributes" + }; + } + } + + if ( defined $push->{extraClaim} ) { + if ( $self->_isSimpleKeyValueHash( $push->{extraClaim} ) ) { + foreach ( keys %{ $push->{extraClaim} } ) { + $conf->{oidcRPMetaDataOptionsExtraClaims}->{$confKey}->{$_} = + $push->{extraClaim}->{$_}; + } + } + else { + return { + res => 'ko', + msg => +"Invalid input: extraClaim is not a hash object with \"key\":\"value\" attributes" + }; + } + } + + # Save configuration + $self->_confAcc->saveConf($conf); + + return { res => 'ok' }; +} + +1; diff --git a/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Api/Providers/SamlSp.pm b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Api/Providers/SamlSp.pm new file mode 100644 index 000000000..a28d7fe8a --- /dev/null +++ b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Api/Providers/SamlSp.pm @@ -0,0 +1,403 @@ +package Lemonldap::NG::Manager::Api::Providers::SamlSp; + +our $VERSION = '2.0.8'; + +package Lemonldap::NG::Manager::Api; + +use 5.10.0; +use utf8; +use Mouse; + +extends 'Lemonldap::NG::Manager::Api::Common'; + +sub getSamlSpByConfKey { + my ( $self, $req ) = @_; + + my $confKey = $req->params('confKey') + or return $self->sendError( $req, 'confKey is missing', 400 ); + + $self->logger->debug("[API] SAML SP $confKey configuration requested"); + + # Get latest configuration + my $conf = $self->_confAcc->getConf; + my $samlSp = $self->_getSamlSpByConfKey( $conf, $confKey ); + + # Check if confKey is defined + return $self->sendError( $req, + "SAML service Provider '$confKey' not found", 404 ) + unless ( defined $samlSp ); + + return $self->sendJSONresponse( $req, $samlSp ); +} + +sub findSamlSpByConfKey { + my ( $self, $req ) = @_; + my $pattern = ( + defined $req->params('uPattern') + ? $req->params('uPattern') + : ( defined $req->params('pattern') ? $req->params('pattern') : undef ) + ); + + return $self->sendError( $req, 'Invalid input: pattern is missing', 405 ) + unless ( defined $pattern ); + + $self->logger->debug( + "[API] Find SAML SPs by confKey regexp $pattern requested"); + + # Get latest configuration + my $conf = $self->_confAcc->getConf; + my @samlSps = + map { $_ =~ $pattern ? $self->_getSamlSpByConfKey( $conf, $_ ) : () } + keys %{ $conf->{samlSPMetaDataXML} }; + + return $self->sendJSONresponse( $req, [@samlSps] ); +} + +sub findSamlSpByEntityId { + my ( $self, $req ) = @_; + my $entityId = ( + defined $req->params('uEntityId') ? $req->params('uEntityId') + : ( + defined $req->params('entityId') ? $req->params('entityId') + : undef + ) + ); + + return $self->sendError( $req, 'entityId is missing', 405 ) + unless ( defined $entityId ); + + $self->logger->debug("[API] Find SAML SPs by entityId $entityId requested"); + + # Get latest configuration + my $conf = $self->_confAcc->getConf; + my $samlSp = $self->_getSamlSpByEntityId( $conf, $entityId ); + + return $self->sendError( $req, + "SAML service Provider with entityID '$entityId' not found", 404 ) + unless ( defined $samlSp ); + return $self->sendJSONresponse( $req, $samlSp ); +} + +sub addSamlSp { + my ( $self, $req ) = @_; + my $add = $req->jsonBodyToObj; + + return $self->sendError( $req, "Invalid input: " . $req->error, 405 ) + unless ($add); + + return $self->sendError( $req, 'Invalid input: confKey is missing', 405 ) + unless ( defined $add->{confKey} ); + + return $self->sendError( $req, 'Invalid input: metadata is missing', 405 ) + unless ( defined $add->{metadata} ); + + my $entityId = $self->_readSamlSpEntityId( $add->{metadata} ); + + return $self->sendError( $req, + 'Invalid input: entityID is missing in metadata', 405 ) + unless ( defined $entityId ); + + $self->logger->debug( +"[API] Add SAML SP with confKey $add->{confKey} and entityID $entityId requested" + ); + + # Get latest configuration + my $conf = $self->_confAcc->getConf( { noCache => 1 } ); + + return $self->sendError( + $req, + "Invalid input: A SAML SP with confKey $add->{confKey} already exists", + 405 + ) if ( defined $self->_getSamlSpByConfKey( $conf, $add->{confKey} ) ); + + return $self->sendError( $req, + "Invalid input: A SAML SP with entityID $entityId already exists", 405 ) + if ( defined $self->_getSamlSpByEntityId( $conf, $entityId ) ); + + my $res = $self->_pushSamlSp( $conf, $add->{confKey}, $add, 1 ); + + return $self->sendError( $req, $res->{msg}, 405 ) + unless ( $res->{res} eq 'ok' ); + + return $self->sendJSONresponse( $req, + { message => "Successful operation" } ); +} + +sub replaceSamlSp { + my ( $self, $req ) = @_; + + my $confKey = $req->params('confKey') + or return $self->sendError( $req, 'confKey is missing', 400 ); + + my $replace = $req->jsonBodyToObj; + + return $self->sendError( $req, "Invalid input: " . $req->error, 405 ) + unless ($replace); + + return $self->sendError( $req, 'Invalid input: metadata is missing', 405 ) + unless ( defined $replace->{metadata} ); + + $self->logger->debug( + "[API] SAML SP $confKey configuration replace requested"); + + # Get latest configuration + my $conf = $self->_confAcc->getConf( { noCache => 1 } ); + + # Return 404 if not found + + return $self->sendError( $req, + "SAML service provider '$confKey' not found", 404 ) + unless ( defined $self->_getSamlSpByConfKey( $conf, $confKey ) ); + + # check if new entityId exists already + my $res = $self->_isNewSamlSpEntityIdUnique( $conf, $confKey, $replace ); + + return $self->sendError( $req, $res->{msg}, 405 ) + unless ( $res->{res} eq 'ok' ); + + $res = $self->_pushSamlSp( $conf, $confKey, $replace, 1 ); + + return $self->sendError( $req, $res->{msg}, 405 ) + unless ( $res->{res} eq 'ok' ); + + return $self->sendJSONresponse( $req, + { message => "Successful operation" } ); +} + +sub updateSamlSp { + my ( $self, $req ) = @_; + my $res; + my $confKey = $req->params('confKey') + or return $self->sendError( $req, 'confKey is missing', 400 ); + + my $update = $req->jsonBodyToObj; + + return $self->sendError( $req, "Invalid input: " . $req->error, 405 ) + unless ($update); + + $self->logger->debug( + "[API] SAML SP $confKey configuration update requested"); + + # Get latest configuration + my $conf = $self->_confAcc->getConf( { noCache => 1 } ); + my $current = $self->_getSamlSpByConfKey( $conf, $confKey ); + + # Return 404 if not found + return $self->sendError( $req, + "SAML service provider '$confKey' not found", 404 ) + unless ( defined $current ); + + if ( defined $update->{metadata} ) { + + # check if new entityId exists already + $res = $self->_isNewSamlSpEntityIdUnique( $conf, $confKey, $update ); + + return $self->sendError( $req, $res->{msg}, 405 ) + unless ( $res->{res} eq 'ok' ); + + } + + $res = $self->_pushSamlSp( $conf, $confKey, $update, 0 ); + return $self->sendError( $req, $res->{msg}, 405 ) + unless ( $res->{res} eq 'ok' ); + + return $self->sendJSONresponse( $req, + { message => "Successful operation" } ); +} + +sub deleteSamlSp { + my ( $self, $req ) = @_; + + my $confKey = $req->params('confKey') + or return $self->sendError( $req, 'confKey is missing', 400 ); + + # Get latest configuration + my $conf = $self->_confAcc->getConf( { noCache => 1 } ); + + my $delete = $self->_getSamlSpByConfKey( $conf, $confKey ); + + # Return 404 if not found + + return $self->sendError( $req, + "SAML service provider '$confKey' not found", 404 ) + unless ( defined $delete ); + + delete $conf->{samlSPMetaDataXML}->{$confKey}; + delete $conf->{samlSPMetaDataOptions}->{$confKey}; + delete $conf->{samlSPMetaDataExportedAttributes}->{$confKey}; + + # Save configuration + $self->_confAcc->saveConf($conf); + + return $self->sendJSONresponse( $req, + { message => "Successful operation" } ); +} + +sub _getSamlSpByConfKey { + my ( $self, $conf, $confKey ) = @_; + + # Check if confKey is defined + return undef unless ( defined $conf->{samlSPMetaDataXML}->{$confKey} ); + + # Get metadata + my $metadata = $conf->{samlSPMetaDataXML}->{$confKey}->{samlSPMetaDataXML}; + + # Get options + my $options = $conf->{samlSPMetaDataOptions}->{$confKey}; + + my $samlSp = { + confKey => $confKey, + metadata => $metadata, + exportedAttributes => {}, + options => $options + }; + + # Get exported attributes + foreach ( keys %{ $conf->{samlSPMetaDataExportedAttributes}->{$confKey} } ) + { + # Extract fields from exportedAttr value + my ( $mandatory, $name, $format, $friendly_name ) = + split( /;/, + $conf->{samlSPMetaDataExportedAttributes}->{$confKey}->{$_} ); + + $mandatory = !!$mandatory ? 'true' : 'false'; # ???????????? + + $samlSp->{exportedAttributes}->{$_} = { + name => $name, + mandatory => $mandatory + }; + + $samlSp->{exportedAttributes}->{$_}->{friendlyName} = $friendly_name + if ( defined $friendly_name && $friendly_name ne '' ); + + $samlSp->{exportedAttributes}->{$_}->{format} = $format + if ( defined $format && $format ne '' ); + } + + return $samlSp; +} + +sub _getSamlSpByEntityId { + my ( $self, $conf, $entityId ) = @_; + + foreach ( keys %{ $conf->{samlSPMetaDataXML} } ) { + return $self->_getSamlSpByConfKey( $conf, $_ ) + if ( + $self->_readSamlSpEntityId( + $conf->{samlSPMetaDataXML}->{$_}->{samlSPMetaDataXML} + ) eq $entityId + ); + } + + return undef; +} + +sub _readSamlSpEntityId { + my ( $self, $metadata ) = @_; + + return ( $metadata =~ /entityID=['"](.+?)['"]/ ) ? $1 : undef; +} + +sub _readSamlSpExportedAttributes { + my ( $self, $attrs, $mergeWith ) = @_; + my $allowedFormats = [ + "urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified", + "urn:oasis:names:tc:SAML:2.0:attrname-format:uri", + "urn:oasis:names:tc:SAML:2.0:attrname-format:basic" + ]; + foreach ( keys %{$attrs} ) { + return { res => "ko", msg => "Exported attribute $_ has no name" } + unless ( defined $attrs->{$_}->{name} ); + my $mandatory = 0; + my $name = $attrs->{$_}->{name}; + my $format = ''; + my $friendlyName = ''; + + ( $mandatory, $name, $format, $friendlyName ) = + split( /;/, $mergeWith->{$_} ) + if ( defined $mergeWith->{$_} ); + + if ( defined $attrs->{$_}->{mandatory} ) { + $mandatory = ( + $attrs->{$_}->{mandatory} eq '1' + or $attrs->{$_}->{mandatory} eq 'true' + ) ? 1 : 0; + } + + if ( defined $attrs->{$_}->{format} ) { + $format = $attrs->{$_}->{format}; + return { + res => "ko", + msg => "Exported attribute $_ format does not exist." + } + unless ( length( grep { /^$format$/ } @{$allowedFormats} ) ); + } + + $friendlyName = $attrs->{$_}->{friendlyName} + if ( defined $attrs->{$_}->{friendlyName} ); + $mergeWith->{$_} = "$mandatory;$name;$format;$friendlyName"; + } + + return { res => "ok", exportedAttributes => $mergeWith }; +} + +sub _pushSamlSp { + my ( $self, $conf, $confKey, $push, $replace ) = @_; + + if ($replace) { + $conf->{samlSPMetaDataXML}->{$confKey} = {}; + $conf->{samlSPMetaDataOptions}->{$confKey} = {}; + $conf->{samlSPMetaDataExportedAttributes}->{$confKey} = {}; + $push->{options} = + $self->_setDefaultValues( $push->{options}, 'samlSPMetaDataNode' ); + } + + $conf->{samlSPMetaDataXML}->{$confKey}->{samlSPMetaDataXML} = + $push->{metadata}; + + if ( defined $push->{options} ) { + my $res = $self->_hasAllowedAttributes( $push->{options}, + 'samlSPMetaDataNode' ); + return $res unless ( $res->{res} eq 'ok' ); + + foreach ( keys %{ $push->{options} } ) { + $conf->{samlSPMetaDataOptions}->{$confKey}->{$_} = + $push->{options}->{$_}; + } + } + + if ( defined $push->{exportedAttributes} ) { + my $res = + $self->_readSamlSpExportedAttributes( $push->{exportedAttributes}, + $conf->{samlSPMetaDataExportedAttributes}->{$confKey} ); + return $res unless ( $res->{res} eq 'ok' ); + + $conf->{samlSPMetaDataExportedAttributes}->{$confKey} = + $res->{exportedAttributes}; + } + + # Save configuration + $self->_confAcc->saveConf($conf); + + return { res => 'ok' }; +} + +sub _isNewSamlSpEntityIdUnique { + my ( $self, $conf, $confKey, $newSp ) = @_; + my $newEntityId = $self->_readSamlSpEntityId( $newSp->{metadata} ); + my $curEntityId = + $self->_readSamlSpEntityId( + $self->_getSamlSpByConfKey( $conf, $confKey )->{metadata} ); + if ( $newEntityId ne $curEntityId ) { + return { + res => 'ko', + msg => +"An SAML service provide with entityId '$newEntityId' already exists" + } + if ( defined $self->_getSamlSpByEntityId( $conf, $newEntityId ) ); + } + + return { res => 'ok' }; +} + +1; diff --git a/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Attributes.pm b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Attributes.pm index ed7ce4560..388f18930 100644 --- a/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Attributes.pm +++ b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Attributes.pm @@ -1086,6 +1086,9 @@ qr/(?:(?:https?):\/\/(?:(?:(?:(?:(?:(?:[a-zA-Z0-9][-a-zA-Z0-9]*)?[a-zA-Z0-9])[.] 'customRegister' => { 'type' => 'text' }, + 'customResetCertByMail' => { + 'type' => 'text' + }, 'customToTrace' => { 'type' => 'lmAttrOrMacro' }, diff --git a/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Build/Attributes.pm b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Build/Attributes.pm index 3b83e2905..036bfd154 100644 --- a/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Build/Attributes.pm +++ b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Build/Attributes.pm @@ -3602,6 +3602,10 @@ m{^(?:ldapi://[^/]*/?|\w[\w\-\.]*(?::\d{1,5})?|ldap(?:s|\+tls)?://\w[\w\-\.]*(?: type => 'text', documentation => 'Custom register module', }, + customResetCertByMail => { + type => 'text', + documentation => 'Custom certificateResetByMail module', + }, customAddParams => { type => 'keyTextContainer', documentation => 'Custom additional parameters', diff --git a/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Build/Tree.pm b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Build/Tree.pm index da2bc35d6..7d0c99a9f 100644 --- a/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Build/Tree.pm +++ b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Build/Tree.pm @@ -435,9 +435,9 @@ sub tree { title => 'customParams', help => 'authcustom.html', nodes => [ - 'customAuth', 'customUserDB', - 'customPassword', 'customRegister', - 'customAddParams', + 'customAuth', 'customUserDB', + 'customPassword', 'customRegister', + 'customResetCertByMail', 'customAddParams', ] }, ], diff --git a/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Cli.pm b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Cli.pm index bb43f30fa..907da93cb 100644 --- a/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Cli.pm +++ b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Cli.pm @@ -21,17 +21,15 @@ has cfgNum => ( } ); -has sep => ( is => 'rw', isa => 'Str', default => '/' ); - -has req => ( is => 'ro' ); - +has log => ( is => 'rw' ); +has req => ( is => 'ro' ); +has sep => ( is => 'rw', isa => 'Str', default => '/' ); has format => ( is => 'rw', isa => 'Str', default => "%-25s | %-25s | %-25s" ); - -has yes => ( is => 'rw', isa => 'Bool', default => 0 ); - -has force => ( is => 'rw', isa => 'Bool', default => 0 ); - -has log => ( is => 'rw' ); +has yes => ( is => 'rw', isa => 'Bool', default => 0 ); +has force => ( is => 'rw', isa => 'Bool', default => 0 ); +has logger => ( is => 'ro', lazy => 1, builder => sub { $_[0]->mgr->logger } ); +has userLogger => + ( is => 'ro', lazy => 1, builder => sub { $_[0]->mgr->userLogger } ); sub get { my ( $self, @keys ) = @_; @@ -60,6 +58,7 @@ sub set { die "$key seems to be a hash, modification refused"; } $oldValue //= ''; + $self->logger->info("CLI: Set key $key with $pairs{$key}"); push @list, [ $key, $oldValue, $pairs{$key} ]; } unless ( $self->yes ) { @@ -96,6 +95,7 @@ sub addKey { unless ( $root =~ /$simpleHashKeys$/o or $root =~ /$sep/o ) { die "$root is not a simple hash. Aborting"; } + $self->logger->info("CLI: Append key $root/$newKey $value"); push @list, [ $root, $newKey, $value ]; } require Clone; @@ -136,6 +136,7 @@ sub delKey { unless ( $root =~ /$simpleHashKeys$/o or $root =~ /$sep/o ) { die "$root is not a simple hash. Aborting"; } + $self->logger->info("CLI: Remove key $root/$key"); push @list, [ $root, $key ]; } require Clone; @@ -191,6 +192,7 @@ sub delKey { sub lastCfg { my ($self) = @_; + $self->logger->info("CLI: Retrieve last conf."); return $self->jsonResponse('/confs/latest')->{cfgNum}; } @@ -218,6 +220,7 @@ sub restore { close $f; die "Empty or malformed file $file" unless ( $conf =~ /\w/s ); } + $self->logger->info("CLI: Restore conf."); my $res = $self->_post( '/confs/raw', '', IO::String->new($conf), 'application/json', length($conf) ); use Data::Dumper; @@ -273,29 +276,34 @@ sub _save { } ); unless ( $parser->testNewConf() ) { + $self->logger->error("CLI: Configuration rejected with message: $parser->{message}"); printf STDERR "Modifications rejected: %s:\n", $parser->{message}; } my $saveParams = { force => $self->force }; if ( $self->force and $self->cfgNum ) { + $self->logger->debug("CLI: cfgNum forced with $self->cfgNum()"); $saveParams->{cfgNum} = $self->cfgNum; $saveParams->{cfgNumFixed} = 1; } - $new->{cfgAuthor} = scalar( getpwuid $< ) . '(command-line)'; + $new->{cfgAuthor} = scalar( getpwuid $< ) . '(command-line-interface)'; chomp $new->{cfgAuthor}; $new->{cfgAuthorIP} = '127.0.0.1'; $new->{cfgDate} = time; $new->{cfgVersion} = $Lemonldap::NG::Manager::VERSION; - $new->{cfgLog} = $self->log // 'Modified using LLNG cli'; + $new->{cfgLog} = $self->log // 'Modified with LL::NG CLI'; $new->{key} ||= join( '', map { chr( int( ord( Crypt::URandom::urandom(1) ) * 94 / 256 ) + 33 ) } ( 1 .. 16 ) ); my $s = $self->mgr->confAcc->saveConf( $new, %$saveParams ); if ( $s > 0 ) { + $self->logger->debug("CLI: Configuration $s has been saved by $new->{cfgAuthor}"); + $self->logger->info("CLI: Configuration $s saved"); print STDERR "Saved under number $s\n"; $parser->{status} = [ $self->mgr->applyConf($new) ]; } else { + $self->logger->error("CLI: Configuration not saved!"); printf STDERR "Modifications rejected: %s:\n", $parser->{message}; print STDERR Dumper($parser); } @@ -336,8 +344,9 @@ sub run { my $action = shift; unless ( $action =~ /^(?:get|set|addKey|delKey|save|restore)$/ ) { die -"unknown action $action. Only get, set, addKey or delKey are accepted"; +"Unknown action $action. Only get, set, addKey or delKey allowed"; } + $self->$action(@_); } @@ -346,7 +355,6 @@ package Lemonldap::NG::Manager::Cli::Request; use Mouse; has cfgNum => ( is => 'rw' ); - has error => ( is => 'rw' ); sub params { diff --git a/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Conf/Tests.pm b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Conf/Tests.pm index e14398e7e..a8e331d80 100644 --- a/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Conf/Tests.pm +++ b/lemonldap-ng-manager/lib/Lemonldap/NG/Manager/Conf/Tests.pm @@ -719,6 +719,21 @@ sub tests { return 1; }, + # Warn if CertificateResetByMail dependencies seem missing + certResetByMailDependencies => sub { + return 1 unless ( $conf->{portalDisplayCertificateResetByMail} ); + return ( 0, +"LDAP RegisterDB is required to enable CertificateResetByMail plugin" + ) unless ( $conf->{registerDB} eq 'LDAP' ); + eval "use DateTime::Format::RFC3339"; + return ( 1, +"DateTime::Format::RFC3339 module is required to enable CertificateResetByMail plugin" + ) if ($@); + + # Return + return 1; + }, + # OIDC redirect URI must not be empty oidcRPRedirectURINotEmpty => sub { return 1 diff --git a/lemonldap-ng-manager/site/htdocs/api.fcgi b/lemonldap-ng-manager/site/htdocs/api.fcgi new file mode 100755 index 000000000..67d37b8d8 --- /dev/null +++ b/lemonldap-ng-manager/site/htdocs/api.fcgi @@ -0,0 +1,12 @@ +#!/usr/bin/perl + +use Plack::Handler::FCGI; +use Lemonldap::NG::Manager; + +# Roll your own +my $server = Plack::Handler::FCGI->new(); +$server->run( + Lemonldap::NG::Manager->run( + { enabledModules => "api", protection => "none" } + ) +); diff --git a/lemonldap-ng-manager/site/htdocs/static/forms/menuApp.html b/lemonldap-ng-manager/site/htdocs/static/forms/menuApp.html index a5dda6368..8f382f6fe 100644 --- a/lemonldap-ng-manager/site/htdocs/static/forms/menuApp.html +++ b/lemonldap-ng-manager/site/htdocs/static/forms/menuApp.html @@ -33,15 +33,20 @@