diff --git a/lemonldap-ng-common/lib/Lemonldap/NG/Common/PSGI.pm b/lemonldap-ng-common/lib/Lemonldap/NG/Common/PSGI.pm index 58aa35c4414dd69c8d1bbae08e22776f86524612..5b243fce87b8f30919d75d5b476c43a53a2f15e6 100644 --- a/lemonldap-ng-common/lib/Lemonldap/NG/Common/PSGI.pm +++ b/lemonldap-ng-common/lib/Lemonldap/NG/Common/PSGI.pm @@ -122,7 +122,8 @@ sub sendJSONresponse { my ( $self, $req, $j, %args ) = @_; $args{code} ||= 200; $args{headers} ||= [ $req->spliceHdrs ]; - my $type = 'application/json; charset=utf-8'; + my $type = $args{type} || 'application/json'; + $type .= '; charset=utf-8'; if ( ref $j ) { eval { $j = $_json->encode($j); }; return $self->sendError( $req, $@ ) if ($@); diff --git a/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/PSGI/Try.pm b/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/PSGI/Try.pm index d31e58f46a16b0106c85daffefacc07b7a4aadd0..a46b23cf932f076a9a6f192a1aef4b0cc580ad6c 100644 --- a/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/PSGI/Try.pm +++ b/lemonldap-ng-handler/lib/Lemonldap/NG/Handler/PSGI/Try.pm @@ -8,17 +8,33 @@ our $VERSION = '2.0.6'; extends 'Lemonldap::NG::Handler::PSGI::Router'; has 'authRoutes' => ( - is => 'rw', - isa => 'HashRef', - default => - sub { { GET => {}, POST => {}, PUT => {}, DELETE => {}, OPTIONS => {} } } + is => 'rw', + isa => 'HashRef', + default => sub { + { + GET => {}, + POST => {}, + PUT => {}, + DELETE => {}, + OPTIONS => {}, + PATCH => {} + } + } ); has 'unAuthRoutes' => ( - is => 'rw', - isa => 'HashRef', - default => - sub { { GET => {}, POST => {}, PUT => {}, DELETE => {}, OPTIONS => {} } } + is => 'rw', + isa => 'HashRef', + default => sub { + { + GET => {}, + POST => {}, + PUT => {}, + DELETE => {}, + OPTIONS => {}, + PATCH => {} + } + } ); sub addRoute { @@ -49,7 +65,7 @@ sub addAuthRouteWithRedirect { sub _auth_and_redirect { my ( $self, $req ) = @_; $self->api->goToPortal( $req, $req->{env}->{REQUEST_URI} ); - return [ 302, [$req->spliceHdrs], [] ]; + return [ 302, [ $req->spliceHdrs ], [] ]; } sub defaultAuthRoute { @@ -73,7 +89,7 @@ sub _run { if ( $res->[0] < 300 ) { $self->routes( $self->authRoutes ); $req->userData( $self->api->data ); - $req->respHeaders($res->[1]); + $req->respHeaders( $res->[1] ); } elsif ( $res->[0] != 403 and not $req->data->{noTry} ) { diff --git a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Lib/LDAP.pm b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Lib/LDAP.pm index 0c40fb5e48e7e57f1fedc92edf666f4f9eb2d924..bba96ef9ecd76e8dc3be44197da51849ad624df4 100644 --- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Lib/LDAP.pm +++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Lib/LDAP.pm @@ -143,6 +143,18 @@ sub getUser { PE_OK; } +sub modifyUser { + my ( $self, $req, $args ) = @_; + + return PE_LDAPCONNECTFAILED unless $self->ldap and $self->bind(); + my $mesg = $self->ldap->modify( $req->data->{dn}, changes => $args ); + if ( $mesg->code() != 0 ) { + $self->logger->error( 'LDAP Modify error: ' . $mesg->error ); + return PE_LDAPERROR; + } + return PE_OK; +} + # Test LDAP connection before trying to bind sub bind { my $self = shift; diff --git a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Plugins.pm b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Plugins.pm index 845f860f3a786069a00751353fc48dc25e59da25..86511424e81e64f6286ae8cc81d3e6a8cd2844e8 100644 --- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Plugins.pm +++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Plugins.pm @@ -24,6 +24,7 @@ our @pList = ( upgradeSession => '::Plugins::Upgrade', autoSigninRules => '::Plugins::AutoSignin', checkState => '::Plugins::CheckState', + scimServer => '::Plugins::SCIMServer', portalForceAuthn => '::Plugins::ForceAuthn', checkUser => '::Plugins::CheckUser', impersonationRule => '::Plugins::Impersonation', diff --git a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/SCIMServer.pm b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/SCIMServer.pm new file mode 100644 index 0000000000000000000000000000000000000000..e41c3f7aa22fb323bee16b9807189c0c5db73e37 --- /dev/null +++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/SCIMServer.pm @@ -0,0 +1,371 @@ +# Server plugin for SCIM request +# See https://tools.ietf.org/html/rfc7644 +# +package Lemonldap::NG::Portal::Plugins::SCIMServer; + +use strict; +use Mouse; +use JSON qw(from_json to_json); +use Lemonldap::NG::Portal::Main::Constants qw( + PE_OK + PE_BADCREDENTIALS +); + +our $VERSION = '2.0.7'; + +extends 'Lemonldap::NG::Portal::Main::Plugin'; + +# INITIALIZATION + +sub init { + my ($self) = @_; + + # Methods inherited from Lemonldap::NG::Common::Session::REST + $self->addUnauthRoute( + scim => { + v2 => { + Users => { ':userid' => 'getUser' } + } + }, + ['GET'] + ); + + $self->addUnauthRoute( + scim => { + v2 => { + Users => { ':userid' => 'modUser' } + } + }, + ['PATCH'] + ); + + return 1; +} + +# ENDPOINTS + +## User retrieval +# https://tools.ietf.org/html/rfc7644#section-3.4.1 + +sub getUser { + my ( $self, $req ) = @_; + my $userid = $req->params('userid'); + unless ($userid) { + + # TODO + return $self->sendScimError( $req, 501, "notImplemented", + "User listing is not implemented yet" ); + } + $self->logger->debug( "SCIM endpoint getUser: " . $req->params('userid') ); + + $req->user($userid); + my $res = $self->p->_userDB->getUser($req); + if ( $res == PE_OK ) { + $self->p->setSessionInfo($req); + $self->p->setMacros($req); + $self->p->setGroups($req); + return $self->sendScimUser($req); + } + elsif ( $res == PE_BADCREDENTIALS ) { + return $self->sendScimError( $req, 404, undef, + "User $userid was not found in UserDB" ); + } + else { + return $self->sendScimError( $req, 500, undef, + "Error $res while looking up user" ); + } +} + +## User modification +# https://tools.ietf.org/html/rfc7644#section-3.5.2 + +sub modUser { + my ( $self, $req ) = @_; + my $userid = $req->params('userid'); + unless ($userid) { + return $self->sendScimError( $req, 404, undef, + "No user was specified in URL" ); + } + + $self->logger->debug( "SCIM endpoint modUser: " . $req->params('userid') ); + + # Make sure the data contains valid JSON + unless ( $req->content_type =~ m#^application/(scim\+)?json# ) { + return $self->sendScimError( $req, 400, 'invalidSyntax', + "Wrong body content type" ); + } + + my $jsonBody; + eval { $jsonBody = from_json( $req->content, { utf8 => 1 } ); }; + if ($@) { + return $self->sendScimError( $req, 400, 'invalidSyntax', + "Invalid JSON body: $@" ); + } + + # Check Schema + unless ( $jsonBody->{schemas} and ref( $jsonBody->{schemas} ) eq "ARRAY" ) { + return $self->sendScimError( $req, 400, 'invalidSyntax', + "Missing 'schemas' attribute" ); + } + + unless ( grep { $_ eq "urn:ietf:params:scim:api:messages:2.0:PatchOp" } + @{ $jsonBody->{schemas} } ) + { + return $self->sendScimError( $req, 400, 'invalidSyntax', + "Schema urn:ietf:params:scim:api:messages:2.0:PatchOp was not found" + ); + } + + # Check Operations attribute + unless ( $jsonBody->{Operations} ) { + return $self->sendScimError( $req, 400, 'invalidSyntax', + "Missing 'Operations' attribute" ); + } + + # Check we're working on an existing user + $req->user($userid); + my $res = $self->p->_userDB->getUser($req); + if ( $res == PE_OK ) { + $self->p->setSessionInfo($req); + $self->p->setMacros($req); + $self->p->setGroups($req); + return $self->modifyScimUser( $req, $jsonBody ); + } + elsif ( $res == PE_BADCREDENTIALS ) { + return $self->sendScimError( $req, 404, undef, + "User $userid was not found in UserDB" ); + } + else { + return $self->sendScimError( $req, 500, undef, + "Error $res while looking up user" ); + } +} + +# Utility methods +sub sendScimError { + my ( $self, $req, $status, $type, $detail ) = @_; + $status //= 500; + my $doc = { + schemas => ["urn:ietf:params:scim:api:messages:2.0:Error"], + ( $type ? ( "scimType" => $type ) : () ), + ( $detail ? ( "detail" => $detail ) : () ), + status => $status, + }; + return $self->sendJSONresponse( + $req, $doc, + code => $status, + type => 'application/scim+json' + ); +} + +sub sendScimUser { + my ( $self, $req ) = @_; + my $doc = { + schemas => ["urn:ietf:params:scim:schemas:core:2.0:User"], + externalId => $req->{sessionInfo}->{ $self->conf->{whatToTrace} }, + id => $req->{user}, + meta => { + resourceType => "User", + }, + }; + + # Single-value, flat attributes + for my $scimAttr ( qw( + userName + displayName + nickName + profileUrl + title + userType + preferredLanguage + locale + timezone + active + ) + ) + { + + if ( $self->_translateattr($scimAttr) + and $req->{sessionInfo}->{ $self->_translateattr($scimAttr) } ) + { + $doc->{$scimAttr} = $self->_single( + $req->{sessionInfo}->{ $self->_translateattr($scimAttr) } ); + } + } + + # Name, complex attribute + for my $namesubkey ( qw( + formatted + familyName + givenName + middleName + honorificPrefix + honorificSuffix + ) + ) + { + if ( $self->_translateattr("name.$namesubkey") + and + $req->{sessionInfo}->{ $self->_translateattr("name.$namesubkey") } ) + { + $doc->{name}->{$namesubkey} = $self->_single( $req->{sessionInfo} + ->{ $self->_translateattr("name.$namesubkey") } ); + } + } + + # Mail, multivalued + my $mailSessionInfo = + $req->{sessionInfo}->{ $self->_translateattr("emails.value") }; + if ($mailSessionInfo) { + foreach my $mail ( + split( $self->{conf}->{multiValuesSeparator}, $mailSessionInfo ) ) + { + push @{ $doc->{emails} }, { value => $mail }; + } + } + return $self->sendJSONresponse( $req, $doc, + type => 'application/scim+json' ); +} + +sub _translateattr { + my ( $self, $attr ) = @_; + my $map = { + displayName => "cn", + title => "title", + "emails.value" => "mail", + "name.formatted" => "cn", + "name.familyName" => "sn", + "name.givenName" => "givenName", + }; + return $map->{$attr}; +} + +sub _single { + my ( $self, $val ) = @_; + my @split = split( $self->{conf}->{multiValuesSeparator}, $val ); + return $split[0]; +} + +sub modifyScimUser { + my ( $self, $req, $jsonBody ) = @_; + my $ldapOperations; + + # TODO: refactor, put the if on operation deeper inside + for my $operation ( @{ $jsonBody->{Operations} } ) { + if ( $operation->{path} ) { + my $ldapOp; + + # Handle flat attributes + if ( my $destAttr = $self->_translateattr( $operation->{path} ) ) { + + # In this case, we expect the new value to be flat too + if ( $operation->{op} eq "remove" ) { + $ldapOp = [ + delete => [ + $destAttr => [], + ] + ]; + } + + # Flat attributes are always single valued, so add and replace + # both map as a replace + elsif ($operation->{op} eq "add" + or $operation->{op} eq "replace" ) + { + # Expect a single value + unless ( ref( $operation->{value} ) ) { + use Data::Dumper; + $Dumper::Useqq = 1; + $self->logger->debug( + "MAXBES " . Dumper( $operation->{value} ) ); + $ldapOp = [ + replace => [ + $destAttr => $operation->{value}, + ] + ]; + } + else { + return $self->sendScimError( $req, 400, "invalidValue", + "Single value required on " . $operation->{path} ); + } + + # Invalid operation + } + else { + return $self->sendScimError( $req, 400, "invalidSyntax", + "Invalid operation " . $operation->{op} ); + } + } + + # Handle multivalued attributes, we are expecting a complex value + # But in LLNG we only care about the .value subelement + elsif ( my $destAttr = + $self->_translateattr( $operation->{path} . ".value" ) ) + { + if ( $operation->{op} eq 'remove' ) { + $ldapOp = [ + delete => [ + $destAttr => [], + ] + ]; + } + else { + if ( ref( $operation->{value} ) eq 'ARRAY' ) { + if ( $operation->{op} eq 'add' ) { + $ldapOp = [ + add => [ + $destAttr => [ + map { $_->{value} || () } + @{ $operation->{value} } + ], + ] + ]; + } + elsif ( $operation->{op} eq 'replace' ) { + $ldapOp = [ + replace => [ + $destAttr => [ + map { $_->{value} || () } + @{ $operation->{value} } + ], + ] + ]; + } + + # Unknown operation + else { + return $self->sendScimError( $req, 400, + "invalidSyntax", + "Invalid operation " . $operation->{op} ); + } + } + else { + return $self->sendScimError( $req, 400, "invalidValue", + "Array required on " . $operation->{path} ); + } + } + } + + # I don't know that attribute + else { + return $self->sendScimError( $req, 400, "invalidPath", + "Attribute " . $operation->{path} . " is not available" ); + } + push @$ldapOperations, @$ldapOp; + } + else { + return $self->sendScimError( $req, 400, "invalidPath", + "No 'path' attribute was provided" ); + } + } + unless ( $self->p->_userDB->modifyUser( $req, $ldapOperations ) == PE_OK ) { + return $self->sendScimError( $req, 500, undef, +"Error during backend modification, ask your administrator to check the server logs" + ); + + } + return $self->getUser($req); + +} + +1; diff --git a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/UserDB/Demo.pm b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/UserDB/Demo.pm index 6edc5a6380cbef60cde6d05c26ae5e8d457856cb..b3af3ced768b88f95cf3468a44ec6b38218e9d3f 100644 --- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/UserDB/Demo.pm +++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/UserDB/Demo.pm @@ -16,19 +16,25 @@ our $VERSION = '2.0.2'; # Sample accounts from Doctor Who characters our %demoAccounts = ( 'rtyler' => { - 'uid' => 'rtyler', - 'cn' => 'Rose Tyler', - 'mail' => 'rtyler@badwolf.org', + 'uid' => 'rtyler', + 'cn' => 'Rose Tyler', + 'givenName' => 'Rose', + 'sn' => 'Tyler', + 'mail' => 'rtyler@badwolf.org', }, 'msmith' => { - 'uid' => 'msmith', - 'cn' => 'Mickey Smith', - 'mail' => 'msmith@badwolf.org', + 'uid' => 'msmith', + 'cn' => 'Mickey Smith', + 'givenName' => 'Mickey', + 'sn' => 'Smith', + 'mail' => 'msmith@badwolf.org', }, 'dwho' => { - 'uid' => 'dwho', - 'cn' => 'Doctor Who', - 'mail' => 'dwho@badwolf.org', + 'uid' => 'dwho', + 'cn' => 'Doctor Who', + 'givenName' => 'John', + 'sn' => 'Smith', + 'mail' => 'dwho@badwolf.org', }, ); @@ -63,6 +69,54 @@ sub getUser { PE_BADCREDENTIALS; } +sub modifyUser { + my ( $self, $req, $args ) = @_; + + return PE_BADCREDENTIALS unless $demoAccounts{ $req->{user} }; + my $user = $req->{user}; + my @args = @$args; + + while ( my $type = shift @args ) { + if ( my $change = shift @args ) { + if ( ref($change) eq 'ARRAY' ) { + while ( my $attr = shift @$change ) { + if ( my $newval = shift @$change ) { + $self->_processOneChange( $user, $type, $attr, + $newval ); + } + } + } + } + } + return PE_OK; +} + +sub _processOneChange { + my ( $self, $user, $type, $attr, $values ) = @_; + use Hash::MultiValue; + my $oldvalues = Hash::MultiValue->new( %{ $demoAccounts{$user} } ); + + if ( $type eq "add" ) { + $oldvalues->add( $attr, ref($values) eq 'ARRAY' ? @$values : $values ); + } + elsif ( $type eq "replace" ) { + $oldvalues->set( $attr, ref($values) eq 'ARRAY' ? @$values : $values ); + } + elsif ( $type eq "delete" ) { + + #TODO: handle delete of only certain values + $oldvalues->remove($attr); + } + + #TODO: locking + $demoAccounts{$user} = {}; + for my $k ( $oldvalues->keys ) { + $demoAccounts{$user}->{$k} = join $self->{conf}->{multiValuesSeparator}, + $oldvalues->get_all($k); + } + return PE_OK; +} + ## @apmethod int setSessionInfo() # Get sample data # @return Lemonldap::NG::Portal constant