Skip to content

Commit

Permalink
feat(pam/integration-tests): Add SSH authentication tests (#583)
Browse files Browse the repository at this point in the history
Repeat all the tests using the actual SSH daemon to simulate better the
real environment.
It comes con various cleanups that are prerequisite to achieve all this
reducing duplications.

This is not using the NSS module yet because that's something to be done
outside the PAM scope.

See each commit for details, from the main one:

```
pam/integration-tests: Add PAM tests using SSHd

We have tests simulating SSH behavior, but it's definitely better to
ensure that SSH works as expected using the actual server and client
when used with authd.

In order to get sshd to be fully usable for this simulation, however, we
need to "mock" it by using a LD_PRELOAD'ed library that has to be in C
(as the cgo version I initially done would trigger the well known issues
we have with go libraries and threads) and that we use it for mocking
the sshd requests on getpwnam and to make sshd to open our pam file
(that is hardcoded in sshd).

To handle the getpwnam we could even have used __nss_configure_lookup()
with a fake module or our own, but this is just a simpler solution for
now, while in future we may want to add full integration tests where
also our own NSS library is used instead, but this was outside the scope
of this change, that is mainly focused on the behavior of the PAM module
only.

As for the rest, just repeat all the native tests that make sense using
SSH instead, by de facto re-using the same tape files, minus the removal
of the user selection.
```

UDENG-4691
  • Loading branch information
3v1n0 authored Nov 14, 2024
2 parents 33931bb + 00f1a92 commit b9fd526
Show file tree
Hide file tree
Showing 210 changed files with 16,853 additions and 2,129 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/qa.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ env:
test_apt_deps: >-
cracklib-runtime
ffmpeg
openssh-client
openssh-server
# In Rust the grpc stubs are generated at build time
# so we always need to install the protobuf compilers
Expand Down
57 changes: 34 additions & 23 deletions examplebroker/broker.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,18 +95,18 @@ type userInfoBroker struct {
var (
exampleUsersMu = sync.RWMutex{}
exampleUsers = map[string]userInfoBroker{
"user1": {Password: "goodpass"},
"user2": {Password: "goodpass"},
"user3": {Password: "goodpass"},
"user-mfa": {Password: "goodpass"},
"user-mfa-with-reset": {Password: "goodpass"},
"user-needs-reset": {Password: "goodpass"},
"user-can-reset": {Password: "goodpass"},
"user-can-reset2": {Password: "goodpass"},
"user-local-groups": {Password: "goodpass"},
"user-pre-check": {Password: "goodpass"},
"user-sudo": {Password: "goodpass"},
"user-mismatching-name": {Password: "goodpass"},
"user1": {Password: "goodpass"},
"user2": {Password: "goodpass"},
"user3": {Password: "goodpass"},
"user-mfa": {Password: "goodpass"},
"user-mfa-with-reset": {Password: "goodpass"},
"user-needs-reset": {Password: "goodpass"},
"user-needs-reset2": {Password: "goodpass"},
"user-can-reset": {Password: "goodpass"},
"user-can-reset2": {Password: "goodpass"},
"user-local-groups": {Password: "goodpass"},
"user-pre-check": {Password: "goodpass"},
"user-sudo": {Password: "goodpass"},
}
)

Expand Down Expand Up @@ -161,6 +161,8 @@ func (b *Broker) NewSession(ctx context.Context, username, lang, mode string) (s
case "user-mfa":
info.neededAuthSteps = 3
case "user-needs-reset":
fallthrough
case "user-needs-reset2":
info.neededAuthSteps = 2
info.pwdChange = mustReset
case "user-can-reset":
Expand Down Expand Up @@ -197,12 +199,24 @@ func (b *Broker) NewSession(ctx context.Context, username, lang, mode string) (s
info.pwdChange = mustReset
}

if _, ok := exampleUsers[username]; !ok && strings.HasPrefix(username, "user-mfa-with-reset-integration") {
exampleUsers[username] = userInfoBroker{Password: "goodpass"}
info.neededAuthSteps = 3
info.pwdChange = canReset
}

if _, ok := exampleUsers[username]; !ok && strings.HasPrefix(username, "user-needs-reset-integration") {
exampleUsers[username] = userInfoBroker{Password: "goodpass"}
info.neededAuthSteps = 2
info.pwdChange = mustReset
}

if _, ok := exampleUsers[username]; !ok && strings.HasPrefix(username, "user-can-reset-integration") {
exampleUsers[username] = userInfoBroker{Password: "goodpass"}
info.neededAuthSteps = 2
info.pwdChange = canReset
}

pubASN1, err := x509.MarshalPKIXPublicKey(&b.privateKey.PublicKey)
if err != nil {
return "", "", err
Expand Down Expand Up @@ -327,7 +341,7 @@ func getSupportedModes(sessionInfo sessionInfo, supportedUILayouts []map[string]
if layout["button"] == "optional" {
allModes["totp_with_button"] = map[string]string{
"selection_label": "Authentication code",
"phone": "+33",
"phone": "+33...",
"wantedCode": "temporary pass",
"ui": mapToJSON(map[string]string{
"type": "form",
Expand All @@ -339,7 +353,7 @@ func getSupportedModes(sessionInfo sessionInfo, supportedUILayouts []map[string]
} else {
allModes["totp"] = map[string]string{
"selection_label": "Authentication code",
"phone": "+33",
"phone": "+33...",
"wantedCode": "temporary pass",
"ui": mapToJSON(map[string]string{
"type": "form",
Expand All @@ -350,21 +364,21 @@ func getSupportedModes(sessionInfo sessionInfo, supportedUILayouts []map[string]
}

allModes["phoneack1"] = map[string]string{
"selection_label": "Use your phone +33",
"phone": "+33",
"selection_label": "Use your phone +33...",
"phone": "+33...",
"ui": mapToJSON(map[string]string{
"type": "form",
"label": "Unlock your phone +33 or accept request on web interface:",
"label": "Unlock your phone +33... or accept request on web interface:",
"wait": "true",
}),
}

allModes["phoneack2"] = map[string]string{
"selection_label": "Use your phone +1",
"phone": "+1",
"selection_label": "Use your phone +1...",
"phone": "+1...",
"ui": mapToJSON(map[string]string{
"type": "form",
"label": "Unlock your phone +1 or accept request on web interface",
"label": "Unlock your phone +1... or accept request on web interface",
"wait": "true",
}),
}
Expand Down Expand Up @@ -888,9 +902,6 @@ func userInfoFromName(name string) string {

case "user-sudo":
user.Groups = append(user.Groups, groupJSONInfo{Name: "sudo", UGID: ""}, groupJSONInfo{Name: "admin", UGID: ""})

case "user-mismatching-name":
user.Name = "mismatching-username"
}

// only used for tests, we can ignore the template execution error as the returned data will be failing.
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ require (
google.golang.org/protobuf v1.34.2
gopkg.in/ini.v1 v1.67.0
gopkg.in/yaml.v3 v3.0.1
gorbe.io/go/osrelease v0.3.0
)

require (
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,5 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorbe.io/go/osrelease v0.3.0 h1:RqVqqfYMbe8AkTrCovTzE+FXYNolUFrhP/ne44za/xA=
gorbe.io/go/osrelease v0.3.0/go.mod h1:kuAQu3QnfzxWIa2eppGpV3ZWAwa/7DP/m465/1I48oU=
38 changes: 32 additions & 6 deletions pam/integration-tests/cli_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main_test

import (
"fmt"
"os"
"os/exec"
"path/filepath"
Expand All @@ -21,13 +22,18 @@ func TestCLIAuthenticate(t *testing.T) {
clientPath := t.TempDir()
cliEnv := preparePamRunnerTest(t, clientPath)
const socketPathEnv = "AUTHD_TESTS_CLI_AUTHENTICATE_TESTS_SOCK"
tapeCommand := fmt.Sprintf("./pam_authd login socket=${%s}", socketPathEnv)

defaultGPasswdOutput, groupsFile := prepareGPasswdFiles(t)
defaultSocketPath := runAuthd(t, defaultGPasswdOutput, groupsFile, true)

tests := map[string]struct {
tape string
tapeSettings []tapeSetting

clientOptions clientOptions
currentUserNotRoot bool
wantLocalGroups bool
}{
"Authenticate user successfully": {
tape: "simple_auth",
Expand Down Expand Up @@ -96,7 +102,8 @@ func TestCLIAuthenticate(t *testing.T) {
tape: "switch_local_broker",
},
"Authenticate user and add it to local group": {
tape: "local_group",
tape: "local_group",
wantLocalGroups: true,
},
"Authenticate with warnings on unsupported arguments": {
tape: "simple_auth_with_unsupported_args",
Expand Down Expand Up @@ -148,11 +155,20 @@ func TestCLIAuthenticate(t *testing.T) {
filepath.Join(outDir, "pam_authd"))
require.NoError(t, err, "Setup: symlinking the pam client")

gpasswdOutput := filepath.Join(outDir, "gpasswd.output")
groupsFile := filepath.Join(testutils.TestFamilyPath(t), "gpasswd.group")
socketPath := runAuthd(t, gpasswdOutput, groupsFile, !tc.currentUserNotRoot)
socketPath := defaultSocketPath
gpasswdOutput := defaultGPasswdOutput
if tc.wantLocalGroups || tc.currentUserNotRoot {
// For the local groups tests we need to run authd again so that it has
// special environment that generates a fake gpasswd output for us to test.
// Similarly for the not-root tests authd has to run in a more restricted way.
// In the other cases this is not needed, so we can just use a shared authd.
var groupsFile string
gpasswdOutput, groupsFile = prepareGPasswdFiles(t)
socketPath = runAuthd(t, gpasswdOutput, groupsFile, !tc.currentUserNotRoot)
}

td := newTapeData(tc.tape, tc.tapeSettings...)
td.Command = tapeCommand
td.Env[socketPathEnv] = socketPath
td.AddClientOptions(t, tc.clientOptions)
td.RunVhs(t, "cli", outDir, cliEnv)
Expand All @@ -172,16 +188,22 @@ func TestCLIChangeAuthTok(t *testing.T) {
cliEnv := preparePamRunnerTest(t, outDir)

const socketPathEnv = "AUTHD_TESTS_CLI_AUTHTOK_TESTS_SOCK"
const tapeBaseCommand = "./pam_authd %s socket=${%s}"
tapeCommand := fmt.Sprintf(tapeBaseCommand, "passwd", socketPathEnv)
defaultSocketPath := runAuthd(t, os.DevNull, os.DevNull, true)

tests := map[string]struct {
tape string
tapeSettings []tapeSetting
tape string
tapeSettings []tapeSetting
tapeVariables map[string]string

currentUserNotRoot bool
}{
"Change password successfully and authenticate with new one": {
tape: "passwd_simple",
tapeVariables: map[string]string{
"AUTHD_TEST_TAPE_LOGIN_COMMAND": fmt.Sprintf(tapeBaseCommand, "login", socketPathEnv),
},
},
"Change passwd after MFA auth": {
tape: "passwd_mfa",
Expand Down Expand Up @@ -224,10 +246,14 @@ func TestCLIChangeAuthTok(t *testing.T) {

socketPath := defaultSocketPath
if tc.currentUserNotRoot {
// For the not-root tests authd has to run in a more restricted way.
// In the other cases this is not needed, so we can just use a shared authd.
socketPath = runAuthd(t, os.DevNull, os.DevNull, false)
}

td := newTapeData(tc.tape, tc.tapeSettings...)
td.Command = tapeCommand
td.Variables = tc.tapeVariables
td.Env[socketPathEnv] = socketPath
td.AddClientOptions(t, clientOptions{})
td.RunVhs(t, "cli", outDir, cliEnv)
Expand Down
13 changes: 7 additions & 6 deletions pam/integration-tests/exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func TestExecModule(t *testing.T) {
t.Fatal("can't test with this libpam version!")
}

libPath := buildExecModuleWithCFlags(t, []string{"-DAUTHD_TEST_EXEC_MODULE"})
libPath := buildExecModuleWithCFlags(t, []string{"-DAUTHD_TEST_EXEC_MODULE"}, false)
execClient := buildExecClient(t)

// We do multiple tests inside this test function not to have to re-compile
Expand Down Expand Up @@ -823,7 +823,7 @@ func TestExecModuleUnimplementedActions(t *testing.T) {
t.Fatal("can't test with this libpam version!")
}

libPath := buildExecModuleWithCFlags(t, nil)
libPath := buildExecModule(t)
execClient := buildExecClient(t)

tx := preparePamTransaction(t, libPath, execClient, nil, "an-user")
Expand All @@ -837,7 +837,7 @@ func getModuleArgs(t *testing.T, clientPath string, args []string) []string {

moduleArgs := []string{"--exec-debug"}
if env := testutils.CoverDirEnv(); env != "" {
moduleArgs = append(moduleArgs, "--exec-env", testutils.CoverDirEnv())
moduleArgs = append(moduleArgs, "--exec-env", env)
}

logFile := os.Stderr.Name()
Expand Down Expand Up @@ -925,16 +925,17 @@ func performAllPAMActions(t *testing.T, tx *pam.Transaction, flags pam.Flags, wa
func buildExecModule(t *testing.T) string {
t.Helper()

return buildExecModuleWithCFlags(t, nil)
return buildExecModuleWithCFlags(t, nil, false)
}

func buildExecModuleWithCFlags(t *testing.T, cFlags []string) string {
func buildExecModuleWithCFlags(t *testing.T, cFlags []string, forPreload bool) string {
t.Helper()

pkgConfigDeps := []string{"gio-2.0", "gio-unix-2.0"}
// t.Name() can be a subtest, so replace the directory slash to get a valid filename.
return buildCPAMModule(t, execModuleSources, pkgConfigDeps, cFlags,
"pam_authd_exec"+strings.ToLower(strings.ReplaceAll(t.Name(), "/", "_")))
"pam_authd_exec"+strings.ToLower(strings.ReplaceAll(t.Name(), "/", "_")),
forPreload)
}

func buildExecClient(t *testing.T) string {
Expand Down
2 changes: 1 addition & 1 deletion pam/integration-tests/gdm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ var testFidoDeviceUILayout = authd.UILayout{

var testPhoneAckUILayout = authd.UILayout{
Type: "form",
Label: ptrValue("Unlock your phone +33 or accept request on web interface:"),
Label: ptrValue("Unlock your phone +33... or accept request on web interface:"),
Content: ptrValue(""),
Wait: ptrValue("true"),
Button: ptrValue(""),
Expand Down
Loading

0 comments on commit b9fd526

Please sign in to comment.