2021-04-13 22:23:14 +00:00
// Copyright 2021 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package integration
import (
"context"
2021-05-27 20:47:10 +00:00
"encoding/base64"
2021-04-13 22:23:14 +00:00
"fmt"
"io"
"net"
"os"
"os/exec"
"strings"
"testing"
"time"
2021-05-17 21:20:41 +00:00
"github.com/stretchr/testify/assert"
2021-04-13 22:23:14 +00:00
"github.com/stretchr/testify/require"
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/user"
"go.pinniped.dev/internal/upstreamldap"
2021-06-22 15:23:19 +00:00
"go.pinniped.dev/test/testlib"
2021-04-13 22:23:14 +00:00
)
func TestLDAPSearch ( t * testing . T ) {
2021-06-22 15:23:19 +00:00
env := testlib . IntegrationEnv ( t )
2021-04-15 00:49:40 +00:00
// Note that these tests depend on the values hard-coded in the LDIF file in test/deploy/tools/ldap.yaml.
// It requires the test LDAP server from the tools deployment.
if len ( env . ToolsNamespace ) == 0 {
t . Skip ( "Skipping test because it requires the test LDAP server in the tools namespace." )
}
2021-04-14 15:12:15 +00:00
2021-04-13 22:23:14 +00:00
ctx , cancelFunc := context . WithCancel ( context . Background ( ) )
t . Cleanup ( func ( ) {
2021-04-15 00:49:40 +00:00
cancelFunc ( ) // this will send SIGKILL to the subprocess, just in case
2021-04-13 22:23:14 +00:00
} )
2021-05-20 00:17:44 +00:00
localhostPorts := findRecentlyUnusedLocalhostPorts ( t , 3 )
ldapLocalhostPort := localhostPorts [ 0 ]
ldapsLocalhostPort := localhostPorts [ 1 ]
unusedLocalhostPort := localhostPorts [ 2 ]
2021-04-15 00:49:40 +00:00
// Expose the the test LDAP server's TLS port on the localhost.
2021-05-20 00:17:44 +00:00
startKubectlPortForward ( ctx , t , ldapsLocalhostPort , "ldaps" , "ldap" , env . ToolsNamespace )
// Expose the the test LDAP server's StartTLS port on the localhost.
startKubectlPortForward ( ctx , t , ldapLocalhostPort , "ldap" , "ldap" , env . ToolsNamespace )
2021-04-13 22:23:14 +00:00
2021-04-15 17:25:35 +00:00
providerConfig := func ( editFunc func ( p * upstreamldap . ProviderConfig ) ) * upstreamldap . ProviderConfig {
2021-05-20 00:17:44 +00:00
providerConfig := defaultProviderConfig ( env , ldapsLocalhostPort )
2021-04-13 22:23:14 +00:00
if editFunc != nil {
2021-04-15 17:25:35 +00:00
editFunc ( providerConfig )
2021-04-13 22:23:14 +00:00
}
2021-04-15 17:25:35 +00:00
return providerConfig
2021-04-13 22:23:14 +00:00
}
2021-04-15 00:49:40 +00:00
pinnyPassword := env . SupervisorUpstreamLDAP . TestUserPassword
2021-04-13 22:23:14 +00:00
2021-05-27 20:47:10 +00:00
b64 := func ( s string ) string {
return base64 . RawURLEncoding . EncodeToString ( [ ] byte ( s ) )
}
2021-04-13 22:23:14 +00:00
tests := [ ] struct {
2021-04-13 23:22:13 +00:00
name string
username string
password string
provider * upstreamldap . Provider
wantError string
wantAuthResponse * authenticator . Response
wantUnauthenticated bool
2021-04-13 22:23:14 +00:00
} {
{
2021-05-20 00:17:44 +00:00
name : "happy path with TLS" ,
2021-04-13 22:23:14 +00:00
username : "pinny" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( nil ) ) ,
2021-04-13 22:23:14 +00:00
wantAuthResponse : & authenticator . Response {
2021-05-27 20:47:10 +00:00
User : & user . DefaultInfo { Name : "pinny" , UID : b64 ( "1000" ) , Groups : [ ] string { "ball-game-players" , "seals" } } ,
2021-04-13 22:23:14 +00:00
} ,
} ,
2021-05-20 00:17:44 +00:00
{
name : "happy path with StartTLS" ,
username : "pinny" ,
password : pinnyPassword ,
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) {
p . Host = "127.0.0.1:" + ldapLocalhostPort
p . ConnectionProtocol = upstreamldap . StartTLS
} ) ) ,
wantAuthResponse : & authenticator . Response {
2021-05-27 20:47:10 +00:00
User : & user . DefaultInfo { Name : "pinny" , UID : b64 ( "1000" ) , Groups : [ ] string { "ball-game-players" , "seals" } } ,
2021-05-20 00:17:44 +00:00
} ,
} ,
2021-04-13 22:23:14 +00:00
{
name : "using a different user search base" ,
username : "pinny" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . UserSearch . Base = "dc=pinniped,dc=dev" } ) ) ,
2021-04-13 22:23:14 +00:00
wantAuthResponse : & authenticator . Response {
2021-05-27 20:47:10 +00:00
User : & user . DefaultInfo { Name : "pinny" , UID : b64 ( "1000" ) , Groups : [ ] string { "ball-game-players" , "seals" } } ,
2021-04-13 22:23:14 +00:00
} ,
} ,
{
name : "when the user search filter is already wrapped by parenthesis" ,
username : "pinny" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . UserSearch . Filter = "(cn={})" } ) ) ,
2021-04-13 22:23:14 +00:00
wantAuthResponse : & authenticator . Response {
2021-05-27 20:47:10 +00:00
User : & user . DefaultInfo { Name : "pinny" , UID : b64 ( "1000" ) , Groups : [ ] string { "ball-game-players" , "seals" } } ,
2021-04-13 22:23:14 +00:00
} ,
} ,
{
name : "when the UsernameAttribute is dn and a user search filter is provided" ,
username : "pinny" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) {
2021-04-13 22:23:14 +00:00
p . UserSearch . UsernameAttribute = "dn"
p . UserSearch . Filter = "cn={}"
2021-04-15 17:25:35 +00:00
} ) ) ,
2021-04-13 22:23:14 +00:00
wantAuthResponse : & authenticator . Response {
2021-05-27 20:47:10 +00:00
User : & user . DefaultInfo { Name : "cn=pinny,ou=users,dc=pinniped,dc=dev" , UID : b64 ( "1000" ) , Groups : [ ] string { "ball-game-players" , "seals" } } ,
2021-04-13 22:23:14 +00:00
} ,
} ,
{
name : "when the user search filter allows for different ways of logging in and the first one is used" ,
username : "pinny" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) {
2021-04-13 22:23:14 +00:00
p . UserSearch . Filter = "(|(cn={})(mail={}))"
2021-04-15 17:25:35 +00:00
} ) ) ,
2021-04-13 22:23:14 +00:00
wantAuthResponse : & authenticator . Response {
2021-05-27 20:47:10 +00:00
User : & user . DefaultInfo { Name : "pinny" , UID : b64 ( "1000" ) , Groups : [ ] string { "ball-game-players" , "seals" } } ,
2021-04-13 22:23:14 +00:00
} ,
} ,
{
name : "when the user search filter allows for different ways of logging in and the second one is used" ,
username : "pinny.ldap@example.com" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) {
2021-04-13 22:23:14 +00:00
p . UserSearch . Filter = "(|(cn={})(mail={}))"
2021-04-15 17:25:35 +00:00
} ) ) ,
2021-04-13 22:23:14 +00:00
wantAuthResponse : & authenticator . Response {
2021-05-27 20:47:10 +00:00
User : & user . DefaultInfo { Name : "pinny" , UID : b64 ( "1000" ) , Groups : [ ] string { "ball-game-players" , "seals" } } ,
2021-04-13 22:23:14 +00:00
} ,
} ,
{
name : "when the UIDAttribute is dn" ,
username : "pinny" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . UserSearch . UIDAttribute = "dn" } ) ) ,
2021-04-13 22:23:14 +00:00
wantAuthResponse : & authenticator . Response {
2021-05-27 20:47:10 +00:00
User : & user . DefaultInfo { Name : "pinny" , UID : b64 ( "cn=pinny,ou=users,dc=pinniped,dc=dev" ) , Groups : [ ] string { "ball-game-players" , "seals" } } ,
2021-04-13 22:23:14 +00:00
} ,
} ,
{
name : "when the UIDAttribute is sn" ,
username : "pinny" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . UserSearch . UIDAttribute = "sn" } ) ) ,
2021-04-13 22:23:14 +00:00
wantAuthResponse : & authenticator . Response {
2021-05-27 20:47:10 +00:00
User : & user . DefaultInfo { Name : "pinny" , UID : b64 ( "Seal" ) , Groups : [ ] string { "ball-game-players" , "seals" } } ,
2021-04-13 22:23:14 +00:00
} ,
} ,
{
name : "when the UsernameAttribute is sn" ,
2021-04-14 15:12:15 +00:00
username : "seAl" , // note that this is not case-sensitive! sn=Seal. The server decides which fields are compared case-sensitive.
2021-04-13 22:23:14 +00:00
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . UserSearch . UsernameAttribute = "sn" } ) ) ,
2021-04-13 22:23:14 +00:00
wantAuthResponse : & authenticator . Response {
2021-05-27 20:47:10 +00:00
User : & user . DefaultInfo { Name : "Seal" , UID : b64 ( "1000" ) , Groups : [ ] string { "ball-game-players" , "seals" } } , // note that the final answer has case preserved from the entry
2021-04-13 22:23:14 +00:00
} ,
} ,
2021-05-27 00:04:20 +00:00
{
name : "when the UsernameAttribute or UIDAttribute are attributes whose value contains UTF-8 data" ,
username : "pinny" ,
password : pinnyPassword ,
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) {
p . UserSearch . Filter = "cn={}"
p . UserSearch . UsernameAttribute = "givenName"
p . UserSearch . UIDAttribute = "givenName"
} ) ) ,
wantAuthResponse : & authenticator . Response {
2021-05-27 20:47:10 +00:00
User : & user . DefaultInfo { Name : "Pinny the 🦭" , UID : b64 ( "Pinny the 🦭" ) , Groups : [ ] string { "ball-game-players" , "seals" } } ,
2021-05-27 00:04:20 +00:00
} ,
} ,
{
name : "when the search filter is searching on an attribute whose value contains UTF-8 data" ,
username : "Pinny the 🦭" ,
password : pinnyPassword ,
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) {
p . UserSearch . Filter = "givenName={}"
p . UserSearch . UsernameAttribute = "cn"
} ) ) ,
wantAuthResponse : & authenticator . Response {
2021-05-27 20:47:10 +00:00
User : & user . DefaultInfo { Name : "pinny" , UID : b64 ( "1000" ) , Groups : [ ] string { "ball-game-players" , "seals" } } ,
2021-05-27 00:04:20 +00:00
} ,
} ,
2021-04-13 22:23:14 +00:00
{
name : "when the UsernameAttribute is dn and there is no user search filter provided" ,
username : "cn=pinny,ou=users,dc=pinniped,dc=dev" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) {
2021-04-13 22:23:14 +00:00
p . UserSearch . UsernameAttribute = "dn"
p . UserSearch . Filter = ""
2021-04-15 17:25:35 +00:00
} ) ) ,
2021-04-13 22:23:14 +00:00
wantError : ` must specify UserSearch Filter when UserSearch UsernameAttribute is "dn" ` ,
} ,
2021-05-17 18:10:26 +00:00
{
name : "group search disabled" ,
username : "pinny" ,
password : pinnyPassword ,
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) {
p . GroupSearch . Base = ""
} ) ) ,
wantAuthResponse : & authenticator . Response {
2021-05-27 20:47:10 +00:00
User : & user . DefaultInfo { Name : "pinny" , UID : b64 ( "1000" ) , Groups : [ ] string { } } ,
2021-05-17 18:10:26 +00:00
} ,
} ,
{
name : "group search base causes no groups to be found for user" ,
username : "pinny" ,
password : pinnyPassword ,
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) {
p . GroupSearch . Base = "ou=users,dc=pinniped,dc=dev" // there are no groups under this part of the tree
} ) ) ,
wantAuthResponse : & authenticator . Response {
2021-05-27 20:47:10 +00:00
User : & user . DefaultInfo { Name : "pinny" , UID : b64 ( "1000" ) , Groups : [ ] string { } } ,
2021-05-17 18:10:26 +00:00
} ,
} ,
{
name : "using dn as the group name attribute" ,
username : "pinny" ,
password : pinnyPassword ,
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) {
p . GroupSearch . GroupNameAttribute = "dn"
} ) ) ,
wantAuthResponse : & authenticator . Response {
2021-05-27 20:47:10 +00:00
User : & user . DefaultInfo { Name : "pinny" , UID : b64 ( "1000" ) , Groups : [ ] string {
2021-05-17 18:10:26 +00:00
"cn=ball-game-players,ou=beach-groups,ou=groups,dc=pinniped,dc=dev" ,
"cn=seals,ou=groups,dc=pinniped,dc=dev" ,
} } ,
} ,
} ,
2021-05-28 20:27:11 +00:00
{
name : "using the default group name attribute, which is dn" ,
username : "pinny" ,
password : pinnyPassword ,
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) {
p . GroupSearch . GroupNameAttribute = ""
} ) ) ,
wantAuthResponse : & authenticator . Response {
User : & user . DefaultInfo { Name : "pinny" , UID : b64 ( "1000" ) , Groups : [ ] string {
"cn=ball-game-players,ou=beach-groups,ou=groups,dc=pinniped,dc=dev" ,
"cn=seals,ou=groups,dc=pinniped,dc=dev" ,
} } ,
} ,
} ,
2021-05-17 18:10:26 +00:00
{
name : "using some other custom group name attribute" ,
username : "pinny" ,
password : pinnyPassword ,
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) {
p . GroupSearch . GroupNameAttribute = "objectClass" // silly example, but still a meaningful test
} ) ) ,
wantAuthResponse : & authenticator . Response {
2021-05-27 20:47:10 +00:00
User : & user . DefaultInfo { Name : "pinny" , UID : b64 ( "1000" ) , Groups : [ ] string { "groupOfNames" , "groupOfNames" } } ,
2021-05-17 18:10:26 +00:00
} ,
} ,
{
name : "using a more complex group search filter" ,
username : "pinny" ,
password : pinnyPassword ,
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) {
p . GroupSearch . Filter = "(&(&(objectClass=groupOfNames)(member={}))(cn=seals))"
} ) ) ,
wantAuthResponse : & authenticator . Response {
2021-05-27 20:47:10 +00:00
User : & user . DefaultInfo { Name : "pinny" , UID : b64 ( "1000" ) , Groups : [ ] string { "seals" } } ,
2021-05-17 18:10:26 +00:00
} ,
} ,
{
name : "using a group filter which causes no groups to be found for the user" ,
username : "pinny" ,
password : pinnyPassword ,
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) {
p . GroupSearch . Filter = "foobar={}" // foobar is not a valid attribute name for this LDAP server's schema
} ) ) ,
wantAuthResponse : & authenticator . Response {
2021-05-27 20:47:10 +00:00
User : & user . DefaultInfo { Name : "pinny" , UID : b64 ( "1000" ) , Groups : [ ] string { } } ,
2021-05-17 18:10:26 +00:00
} ,
} ,
2021-04-13 22:23:14 +00:00
{
name : "when the bind user username is not a valid DN" ,
username : "pinny" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . BindUsername = "invalid-dn" } ) ) ,
2021-04-13 22:23:14 +00:00
wantError : ` error binding as "invalid-dn" before user search: LDAP Result Code 34 "Invalid DN Syntax": invalid DN ` ,
} ,
{
name : "when the bind user username is wrong" ,
username : "pinny" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . BindUsername = "cn=wrong,dc=pinniped,dc=dev" } ) ) ,
2021-04-13 22:23:14 +00:00
wantError : ` error binding as "cn=wrong,dc=pinniped,dc=dev" before user search: LDAP Result Code 49 "Invalid Credentials": ` ,
} ,
{
name : "when the bind user password is wrong" ,
username : "pinny" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . BindPassword = "wrong-password" } ) ) ,
2021-04-13 22:23:14 +00:00
wantError : ` error binding as "cn=admin,dc=pinniped,dc=dev" before user search: LDAP Result Code 49 "Invalid Credentials": ` ,
} ,
2021-05-20 00:17:44 +00:00
{
name : "when the bind user username is wrong with StartTLS: example of an error after successful connection with StartTLS" ,
username : "pinny" ,
password : pinnyPassword ,
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) {
p . Host = "127.0.0.1:" + ldapLocalhostPort
p . ConnectionProtocol = upstreamldap . StartTLS
p . BindUsername = "cn=wrong,dc=pinniped,dc=dev"
} ) ) ,
wantError : ` error binding as "cn=wrong,dc=pinniped,dc=dev" before user search: LDAP Result Code 49 "Invalid Credentials": ` ,
} ,
2021-04-13 22:23:14 +00:00
{
2021-04-13 23:22:13 +00:00
name : "when the end user password is wrong" ,
username : "pinny" ,
password : "wrong-pinny-password" ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( nil ) ) ,
2021-04-13 23:22:13 +00:00
wantUnauthenticated : true ,
2021-04-13 22:23:14 +00:00
} ,
2021-04-14 15:12:15 +00:00
{
name : "when the end user password has the wrong case (passwords are compared as case-sensitive)" ,
username : "pinny" ,
password : strings . ToUpper ( pinnyPassword ) ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( nil ) ) ,
2021-04-14 15:12:15 +00:00
wantUnauthenticated : true ,
} ,
2021-04-13 22:23:14 +00:00
{
2021-04-13 23:22:13 +00:00
name : "when the end user username is wrong" ,
username : "wrong-username" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( nil ) ) ,
2021-04-13 23:22:13 +00:00
wantUnauthenticated : true ,
2021-04-13 22:23:14 +00:00
} ,
{
name : "when the user search filter does not compile" ,
username : "pinny" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . UserSearch . Filter = "*" } ) ) ,
2021-05-28 21:37:31 +00:00
wantError : ` error searching for user: LDAP Result Code 201 "Filter Compile Error": ldap: error parsing filter ` ,
2021-04-13 22:23:14 +00:00
} ,
2021-05-17 18:10:26 +00:00
{
name : "when the group search filter does not compile" ,
username : "pinny" ,
password : pinnyPassword ,
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . GroupSearch . Filter = "*" } ) ) ,
wantError : ` error searching for group memberships for user with DN "cn=pinny,ou=users,dc=pinniped,dc=dev": LDAP Result Code 201 "Filter Compile Error": ldap: error parsing filter ` ,
} ,
2021-04-13 22:23:14 +00:00
{
name : "when there are too many search results for the user" ,
username : "pinny" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) {
2021-04-13 22:23:14 +00:00
p . UserSearch . Filter = "objectClass=*" // overly broad search filter
2021-04-15 17:25:35 +00:00
} ) ) ,
2021-05-28 21:37:31 +00:00
wantError : ` error searching for user: LDAP Result Code 4 "Size Limit Exceeded": ` ,
2021-04-13 22:23:14 +00:00
} ,
{
2021-05-20 00:17:44 +00:00
name : "when the server is unreachable with TLS" ,
2021-04-13 22:23:14 +00:00
username : "pinny" ,
password : pinnyPassword ,
2021-05-20 00:17:44 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . Host = "127.0.0.1:" + unusedLocalhostPort } ) ) ,
wantError : fmt . Sprintf ( ` error dialing host "127.0.0.1:%s": LDAP Result Code 200 "Network Error": dial tcp 127.0.0.1:%s: connect: connection refused ` , unusedLocalhostPort , unusedLocalhostPort ) ,
2021-04-13 22:23:14 +00:00
} ,
{
2021-05-20 00:17:44 +00:00
name : "when the server is unreachable with StartTLS" ,
username : "pinny" ,
password : pinnyPassword ,
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) {
p . Host = "127.0.0.1:" + unusedLocalhostPort
p . ConnectionProtocol = upstreamldap . StartTLS
} ) ) ,
wantError : fmt . Sprintf ( ` error dialing host "127.0.0.1:%s": LDAP Result Code 200 "Network Error": dial tcp 127.0.0.1:%s: connect: connection refused ` , unusedLocalhostPort , unusedLocalhostPort ) ,
} ,
{
name : "when the server is not parsable with TLS" ,
2021-04-13 22:23:14 +00:00
username : "pinny" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . Host = "too:many:ports" } ) ) ,
2021-05-25 19:46:50 +00:00
wantError : ` error dialing host "too:many:ports": LDAP Result Code 200 "Network Error": host "too:many:ports" is not a valid hostname or IP address ` ,
2021-04-13 22:23:14 +00:00
} ,
{
2021-05-20 00:17:44 +00:00
name : "when the server is not parsable with StartTLS" ,
username : "pinny" ,
password : pinnyPassword ,
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) {
p . Host = "127.0.0.1:" + ldapLocalhostPort
p . ConnectionProtocol = upstreamldap . StartTLS
p . Host = "too:many:ports"
} ) ) ,
2021-05-25 19:46:50 +00:00
wantError : ` error dialing host "too:many:ports": LDAP Result Code 200 "Network Error": host "too:many:ports" is not a valid hostname or IP address ` ,
2021-05-20 00:17:44 +00:00
} ,
{
name : "when the CA bundle is not parsable with TLS" ,
2021-04-13 22:23:14 +00:00
username : "pinny" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . CABundle = [ ] byte ( "invalid-pem" ) } ) ) ,
2021-05-20 00:17:44 +00:00
wantError : fmt . Sprintf ( ` error dialing host "127.0.0.1:%s": LDAP Result Code 200 "Network Error": could not parse CA bundle ` , ldapsLocalhostPort ) ,
2021-04-13 22:23:14 +00:00
} ,
{
2021-05-20 00:17:44 +00:00
name : "when the CA bundle is not parsable with StartTLS" ,
username : "pinny" ,
password : pinnyPassword ,
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) {
p . Host = "127.0.0.1:" + ldapLocalhostPort
p . ConnectionProtocol = upstreamldap . StartTLS
p . CABundle = [ ] byte ( "invalid-pem" )
} ) ) ,
wantError : fmt . Sprintf ( ` error dialing host "127.0.0.1:%s": LDAP Result Code 200 "Network Error": could not parse CA bundle ` , ldapLocalhostPort ) ,
} ,
{
name : "when the CA bundle does not cause the host to be trusted with TLS" ,
2021-04-13 22:23:14 +00:00
username : "pinny" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . CABundle = nil } ) ) ,
2021-05-20 00:17:44 +00:00
wantError : fmt . Sprintf ( ` error dialing host "127.0.0.1:%s": LDAP Result Code 200 "Network Error": x509: certificate signed by unknown authority ` , ldapsLocalhostPort ) ,
} ,
{
name : "when the CA bundle does not cause the host to be trusted with StartTLS" ,
username : "pinny" ,
password : pinnyPassword ,
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) {
p . Host = "127.0.0.1:" + ldapLocalhostPort
p . ConnectionProtocol = upstreamldap . StartTLS
p . CABundle = nil
} ) ) ,
wantError : fmt . Sprintf ( ` error dialing host "127.0.0.1:%s": LDAP Result Code 200 "Network Error": TLS handshake failed (x509: certificate signed by unknown authority) ` , ldapLocalhostPort ) ,
} ,
{
name : "when trying to use TLS to connect to a port which only supports StartTLS" ,
username : "pinny" ,
password : pinnyPassword ,
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . Host = "127.0.0.1:" + ldapLocalhostPort } ) ) ,
wantError : fmt . Sprintf ( ` error dialing host "127.0.0.1:%s": LDAP Result Code 200 "Network Error": EOF ` , ldapLocalhostPort ) ,
} ,
{
name : "when trying to use StartTLS to connect to a port which only supports TLS" ,
username : "pinny" ,
password : pinnyPassword ,
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . ConnectionProtocol = upstreamldap . StartTLS } ) ) ,
wantError : fmt . Sprintf ( ` error dialing host "127.0.0.1:%s": unable to read LDAP response packet: unexpected EOF ` , ldapsLocalhostPort ) ,
2021-04-13 22:23:14 +00:00
} ,
{
name : "when the UsernameAttribute attribute has multiple values in the entry" ,
username : "wally.ldap@example.com" ,
2021-04-15 00:49:40 +00:00
password : "unused-because-error-is-before-bind" ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . UserSearch . UsernameAttribute = "mail" } ) ) ,
2021-04-13 22:23:14 +00:00
wantError : ` found 2 values for attribute "mail" while searching for user "wally.ldap@example.com", but expected 1 result ` ,
} ,
{
name : "when the UIDAttribute attribute has multiple values in the entry" ,
username : "wally" ,
2021-04-15 00:49:40 +00:00
password : "unused-because-error-is-before-bind" ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . UserSearch . UIDAttribute = "mail" } ) ) ,
2021-04-13 22:23:14 +00:00
wantError : ` found 2 values for attribute "mail" while searching for user "wally", but expected 1 result ` ,
} ,
{
name : "when the UsernameAttribute attribute is not found in the entry" ,
username : "wally" ,
2021-04-15 00:49:40 +00:00
password : "unused-because-error-is-before-bind" ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) {
2021-04-13 22:23:14 +00:00
p . UserSearch . Filter = "cn={}"
p . UserSearch . UsernameAttribute = "attr-does-not-exist"
2021-04-15 17:25:35 +00:00
} ) ) ,
2021-04-13 22:23:14 +00:00
wantError : ` found 0 values for attribute "attr-does-not-exist" while searching for user "wally", but expected 1 result ` ,
} ,
{
name : "when the UIDAttribute attribute is not found in the entry" ,
username : "wally" ,
2021-04-15 00:49:40 +00:00
password : "unused-because-error-is-before-bind" ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . UserSearch . UIDAttribute = "attr-does-not-exist" } ) ) ,
2021-04-13 22:23:14 +00:00
wantError : ` found 0 values for attribute "attr-does-not-exist" while searching for user "wally", but expected 1 result ` ,
} ,
{
name : "when the UsernameAttribute has the wrong case" ,
username : "Seal" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . UserSearch . UsernameAttribute = "SN" } ) ) , // this is case-sensitive
2021-04-13 22:23:14 +00:00
wantError : ` found 0 values for attribute "SN" while searching for user "Seal", but expected 1 result ` ,
} ,
{
name : "when the UIDAttribute has the wrong case" ,
username : "pinny" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . UserSearch . UIDAttribute = "SN" } ) ) , // this is case-sensitive
2021-04-13 22:23:14 +00:00
wantError : ` found 0 values for attribute "SN" while searching for user "pinny", but expected 1 result ` ,
} ,
2021-05-17 18:10:26 +00:00
{
name : "when the GroupNameAttribute has the wrong case" ,
username : "pinny" ,
password : pinnyPassword ,
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . GroupSearch . GroupNameAttribute = "CN" } ) ) , // this is case-sensitive
wantError : ` error searching for group memberships for user with DN "cn=pinny,ou=users,dc=pinniped,dc=dev": found 0 values for attribute "CN" while searching for user "cn=pinny,ou=users,dc=pinniped,dc=dev", but expected 1 result ` ,
} ,
2021-04-13 22:23:14 +00:00
{
name : "when the UsernameAttribute is DN and has the wrong case" ,
username : "pinny" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) {
2021-04-13 22:23:14 +00:00
p . UserSearch . UsernameAttribute = "DN" // dn must be lower-case
p . UserSearch . Filter = "cn={}"
2021-04-15 17:25:35 +00:00
} ) ) ,
2021-04-13 22:23:14 +00:00
wantError : ` found 0 values for attribute "DN" while searching for user "pinny", but expected 1 result ` ,
} ,
{
name : "when the UIDAttribute is DN and has the wrong case" ,
username : "pinny" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) {
2021-04-13 22:23:14 +00:00
p . UserSearch . UIDAttribute = "DN" // dn must be lower-case
2021-04-15 17:25:35 +00:00
} ) ) ,
2021-04-13 22:23:14 +00:00
wantError : ` found 0 values for attribute "DN" while searching for user "pinny", but expected 1 result ` ,
} ,
{
2021-05-17 18:10:26 +00:00
name : "when the GroupNameAttribute is DN and has the wrong case" ,
username : "pinny" ,
password : pinnyPassword ,
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) {
p . GroupSearch . GroupNameAttribute = "DN" // dn must be lower-case
} ) ) ,
wantError : ` error searching for group memberships for user with DN "cn=pinny,ou=users,dc=pinniped,dc=dev": found 0 values for attribute "DN" while searching for user "cn=pinny,ou=users,dc=pinniped,dc=dev", but expected 1 result ` ,
} ,
{
name : "when the user search base is invalid" ,
2021-04-13 22:23:14 +00:00
username : "pinny" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . UserSearch . Base = "invalid-base" } ) ) ,
2021-05-28 21:37:31 +00:00
wantError : ` error searching for user: LDAP Result Code 34 "Invalid DN Syntax": invalid DN ` ,
2021-04-13 22:23:14 +00:00
} ,
{
2021-05-17 18:10:26 +00:00
name : "when the group search base is invalid" ,
username : "pinny" ,
password : pinnyPassword ,
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . GroupSearch . Base = "invalid-base" } ) ) ,
wantError : ` error searching for group memberships for user with DN "cn=pinny,ou=users,dc=pinniped,dc=dev": LDAP Result Code 34 "Invalid DN Syntax": invalid DN ` ,
} ,
{
name : "when the user search base does not exist" ,
2021-04-13 22:23:14 +00:00
username : "pinny" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . UserSearch . Base = "ou=does-not-exist,dc=pinniped,dc=dev" } ) ) ,
2021-05-28 21:37:31 +00:00
wantError : ` error searching for user: LDAP Result Code 32 "No Such Object": ` ,
2021-04-13 22:23:14 +00:00
} ,
{
2021-05-17 18:10:26 +00:00
name : "when the group search base does not exist" ,
username : "pinny" ,
password : pinnyPassword ,
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . GroupSearch . Base = "ou=does-not-exist,dc=pinniped,dc=dev" } ) ) ,
wantError : ` error searching for group memberships for user with DN "cn=pinny,ou=users,dc=pinniped,dc=dev": LDAP Result Code 32 "No Such Object": ` ,
} ,
{
name : "when the user search base causes no search results" ,
2021-04-13 23:22:13 +00:00
username : "pinny" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( func ( p * upstreamldap . ProviderConfig ) { p . UserSearch . Base = "ou=groups,dc=pinniped,dc=dev" } ) ) ,
2021-04-13 23:22:13 +00:00
wantUnauthenticated : true ,
2021-04-13 22:23:14 +00:00
} ,
{
2021-04-13 23:22:13 +00:00
name : "when there is no username specified" ,
username : "" ,
password : pinnyPassword ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( nil ) ) ,
2021-04-13 23:22:13 +00:00
wantUnauthenticated : true ,
2021-04-13 22:23:14 +00:00
} ,
{
name : "when there is no password specified" ,
username : "pinny" ,
password : "" ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( nil ) ) ,
2021-04-13 22:23:14 +00:00
wantError : ` error binding for user "pinny" using provided password against DN "cn=pinny,ou=users,dc=pinniped,dc=dev": LDAP Result Code 206 "Empty password not allowed by the client": ldap: empty password not allowed by the client ` ,
} ,
{
2021-04-13 23:22:13 +00:00
name : "when the user has no password in their entry" ,
username : "olive" ,
password : "anything" ,
2021-04-15 17:25:35 +00:00
provider : upstreamldap . New ( * providerConfig ( nil ) ) ,
2021-04-13 23:22:13 +00:00
wantUnauthenticated : true ,
2021-04-13 22:23:14 +00:00
} ,
}
for _ , test := range tests {
tt := test
t . Run ( tt . name , func ( t * testing . T ) {
authResponse , authenticated , err := tt . provider . AuthenticateUser ( ctx , tt . username , tt . password )
2021-04-13 23:22:13 +00:00
switch {
case tt . wantError != "" :
2021-04-13 22:23:14 +00:00
require . EqualError ( t , err , tt . wantError )
2021-04-15 00:49:40 +00:00
require . False ( t , authenticated , "expected the user not to be authenticated, but they were" )
2021-04-13 22:23:14 +00:00
require . Nil ( t , authResponse )
2021-04-13 23:22:13 +00:00
case tt . wantUnauthenticated :
require . NoError ( t , err )
2021-04-15 00:49:40 +00:00
require . False ( t , authenticated , "expected the user not to be authenticated, but they were" )
2021-04-13 23:22:13 +00:00
require . Nil ( t , authResponse )
default :
2021-04-13 22:23:14 +00:00
require . NoError ( t , err )
2021-04-15 00:49:40 +00:00
require . True ( t , authenticated , "expected the user to be authenticated, but they were not" )
2021-04-13 22:23:14 +00:00
require . Equal ( t , tt . wantAuthResponse , authResponse )
}
} )
}
}
2021-05-27 20:47:10 +00:00
func TestSimultaneousLDAPRequestsOnSingleProvider ( t * testing . T ) {
2021-06-22 15:23:19 +00:00
env := testlib . IntegrationEnv ( t )
2021-04-15 17:25:35 +00:00
// Note that these tests depend on the values hard-coded in the LDIF file in test/deploy/tools/ldap.yaml.
// It requires the test LDAP server from the tools deployment.
if len ( env . ToolsNamespace ) == 0 {
t . Skip ( "Skipping test because it requires the test LDAP server in the tools namespace." )
}
ctx , cancelFunc := context . WithCancel ( context . Background ( ) )
t . Cleanup ( func ( ) {
cancelFunc ( ) // this will send SIGKILL to the subprocess, just in case
} )
ldapHostPort := findRecentlyUnusedLocalhostPorts ( t , 1 ) [ 0 ]
// Expose the the test LDAP server's TLS port on the localhost.
startKubectlPortForward ( ctx , t , ldapHostPort , "ldaps" , "ldap" , env . ToolsNamespace )
provider := upstreamldap . New ( * defaultProviderConfig ( env , ldapHostPort ) )
2021-05-27 20:47:10 +00:00
b64 := func ( s string ) string {
return base64 . RawURLEncoding . EncodeToString ( [ ] byte ( s ) )
}
2021-04-15 17:25:35 +00:00
// Making multiple simultaneous requests on the same upstreamldap.Provider instance should all succeed
// without triggering the race detector.
iterations := 150
resultCh := make ( chan authUserResult , iterations )
for i := 0 ; i < iterations ; i ++ {
go func ( ) {
2021-05-17 21:20:41 +00:00
authUserCtx , authUserCtxCancelFunc := context . WithTimeout ( context . Background ( ) , 2 * time . Minute )
defer authUserCtxCancelFunc ( )
authResponse , authenticated , err := provider . AuthenticateUser ( authUserCtx ,
2021-04-15 17:25:35 +00:00
env . SupervisorUpstreamLDAP . TestUserCN , env . SupervisorUpstreamLDAP . TestUserPassword ,
)
resultCh <- authUserResult {
response : authResponse ,
authenticated : authenticated ,
err : err ,
}
} ( )
}
for i := 0 ; i < iterations ; i ++ {
result := <- resultCh
2021-05-17 21:20:41 +00:00
// Record failures but allow the test to keep running so that all the background goroutines have a chance to try.
assert . NoError ( t , result . err )
assert . True ( t , result . authenticated , "expected the user to be authenticated, but they were not" )
assert . Equal ( t , & authenticator . Response {
2021-05-27 20:47:10 +00:00
User : & user . DefaultInfo { Name : "pinny" , UID : b64 ( "1000" ) , Groups : [ ] string { "ball-game-players" , "seals" } } ,
2021-04-15 17:25:35 +00:00
} , result . response )
}
}
type authUserResult struct {
response * authenticator . Response
authenticated bool
err error
}
2021-06-22 15:23:19 +00:00
func defaultProviderConfig ( env * testlib . TestEnv , port string ) * upstreamldap . ProviderConfig {
2021-04-15 17:25:35 +00:00
return & upstreamldap . ProviderConfig {
2021-05-20 00:17:44 +00:00
Name : "test-ldap-provider" ,
Host : "127.0.0.1:" + port ,
ConnectionProtocol : upstreamldap . TLS ,
CABundle : [ ] byte ( env . SupervisorUpstreamLDAP . CABundle ) ,
BindUsername : "cn=admin,dc=pinniped,dc=dev" ,
BindPassword : "password" ,
2021-04-15 17:25:35 +00:00
UserSearch : upstreamldap . UserSearchConfig {
Base : "ou=users,dc=pinniped,dc=dev" ,
Filter : "" , // defaults to UsernameAttribute={}, i.e. "cn={}" in this case
UsernameAttribute : "cn" ,
UIDAttribute : "uidNumber" ,
} ,
2021-05-17 18:10:26 +00:00
GroupSearch : upstreamldap . GroupSearchConfig {
Base : "ou=groups,dc=pinniped,dc=dev" ,
2021-05-28 20:27:11 +00:00
Filter : "" , // defaults to member={}
GroupNameAttribute : "cn" , // defaults to dn, but here we set it to cn
2021-05-17 18:10:26 +00:00
} ,
2021-04-15 17:25:35 +00:00
}
}
2021-04-15 00:49:40 +00:00
func startKubectlPortForward ( ctx context . Context , t * testing . T , hostPort , remotePort , serviceName , namespace string ) {
2021-04-13 22:23:14 +00:00
t . Helper ( )
2021-04-15 00:49:40 +00:00
startLongRunningCommandAndWaitForInitialOutput ( ctx , t ,
"kubectl" ,
[ ] string {
"port-forward" ,
fmt . Sprintf ( "service/%s" , serviceName ) ,
fmt . Sprintf ( "%s:%s" , hostPort , remotePort ) ,
"-n" , namespace ,
} ,
"Forwarding from " ,
"stdout" ,
)
2021-04-13 22:23:14 +00:00
}
2021-04-15 00:49:40 +00:00
func findRecentlyUnusedLocalhostPorts ( t * testing . T , howManyPorts int ) [ ] string {
2021-04-13 22:23:14 +00:00
t . Helper ( )
2021-04-15 00:49:40 +00:00
listeners := [ ] net . Listener { }
for i := 0 ; i < howManyPorts ; i ++ {
unusedPortGrabbingListener , err := net . Listen ( "tcp" , "127.0.0.1:0" )
2021-04-13 22:23:14 +00:00
require . NoError ( t , err )
2021-04-15 00:49:40 +00:00
listeners = append ( listeners , unusedPortGrabbingListener )
}
2021-04-13 22:23:14 +00:00
2021-04-15 00:49:40 +00:00
ports := make ( [ ] string , len ( listeners ) )
for i , listener := range listeners {
splitHostAndPort := strings . Split ( listener . Addr ( ) . String ( ) , ":" )
require . Len ( t , splitHostAndPort , 2 )
ports [ i ] = splitHostAndPort [ 1 ]
}
2021-04-13 22:23:14 +00:00
2021-04-15 00:49:40 +00:00
for _ , listener := range listeners {
require . NoError ( t , listener . Close ( ) )
2021-04-13 22:23:14 +00:00
}
2021-04-15 00:49:40 +00:00
return ports
}
func startLongRunningCommandAndWaitForInitialOutput (
ctx context . Context ,
t * testing . T ,
command string ,
args [ ] string ,
waitForOutputToContain string ,
waitForOutputOnFd string , // can be either "stdout" or "stderr"
) {
t . Helper ( )
t . Logf ( "Starting: %s %s" , command , strings . Join ( args , " " ) )
2021-04-13 22:23:14 +00:00
2021-04-15 00:49:40 +00:00
cmd := exec . CommandContext ( ctx , command , args ... )
2021-04-13 22:23:14 +00:00
var stdoutBuf , stderrBuf syncBuffer
cmd . Stdout = & stdoutBuf
cmd . Stderr = & stderrBuf
cmd . Stdout = io . MultiWriter ( os . Stdout , & stdoutBuf )
cmd . Stderr = io . MultiWriter ( os . Stderr , & stderrBuf )
2021-04-15 00:49:40 +00:00
var watchOn * syncBuffer
switch waitForOutputOnFd {
case "stdout" :
watchOn = & stdoutBuf
case "stderr" :
watchOn = & stderrBuf
default :
t . Fatalf ( "oops bad argument" )
}
err := cmd . Start ( )
2021-04-13 22:23:14 +00:00
require . NoError ( t , err )
t . Cleanup ( func ( ) {
2021-04-15 00:49:40 +00:00
// If the cancellation of ctx was already scheduled in a t.Cleanup, then this
// t.Cleanup is registered after the one, so this one will happen first.
// Cancelling ctx will send SIGKILL, which will act as a backup in case
// the process ignored this SIGINT.
2021-04-13 22:23:14 +00:00
err := cmd . Process . Signal ( os . Interrupt )
require . NoError ( t , err )
} )
2021-06-22 15:23:19 +00:00
testlib . RequireEventually ( t , func ( requireEventually * require . Assertions ) {
2021-04-15 00:49:40 +00:00
t . Logf ( ` Waiting for %s to emit output: "%s" ` , command , waitForOutputToContain )
2021-06-16 22:51:23 +00:00
requireEventually . Equal ( - 1 , cmd . ProcessState . ExitCode ( ) , "subcommand ended sooner than expected" )
requireEventually . Contains ( watchOn . String ( ) , waitForOutputToContain , "expected process to emit output" )
2021-04-15 00:49:40 +00:00
} , 1 * time . Minute , 1 * time . Second )
2021-04-13 22:23:14 +00:00
2021-04-15 00:49:40 +00:00
t . Logf ( "Detected that %s has started successfully" , command )
2021-04-13 22:23:14 +00:00
}