diff --git a/cmd/admin.go b/cmd/admin.go index 90157e2d5a..60b25eb971 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -64,6 +64,8 @@ func subcmdAuth() *cli.Command { microcmdAuthUpdateLdapBindDn(), microcmdAuthAddLdapSimpleAuth(), microcmdAuthUpdateLdapSimpleAuth(), + microcmdAuthAddPAM(), + microcmdAuthUpdatePAM(), microcmdAuthAddSMTP(), microcmdAuthUpdateSMTP(), microcmdAuthList(), diff --git a/cmd/admin_auth_pam.go b/cmd/admin_auth_pam.go new file mode 100644 index 0000000000..25e32503e0 --- /dev/null +++ b/cmd/admin_auth_pam.go @@ -0,0 +1,145 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "errors" + + auth_model "forgejo.org/models/auth" + "forgejo.org/services/auth/source/pam" + + "github.com/urfave/cli/v3" +) + +func pamCLIFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "name", + Value: "", + Usage: "Application Name", + }, + &cli.StringFlag{ + Name: "service-name", + Value: "PLAIN", + Usage: "PAM service name", + }, + &cli.StringFlag{ + Name: "email-domain", + Value: "", + Usage: "PAM email domain", + }, + &cli.BoolFlag{ + Name: "skip-local-2fa", + Usage: "Skip 2FA to log on.", + Value: true, + }, + &cli.BoolFlag{ + Name: "active", + Usage: "This Authentication Source is Activated.", + Value: true, + }, + } +} + +func microcmdAuthAddPAM() *cli.Command { + return &cli.Command{ + Name: "add-pam", + Usage: "Add new PAM authentication source", + Before: noDanglingArgs, + Action: newAuthService().addPAM, + Flags: pamCLIFlags(), + } +} + +func microcmdAuthUpdatePAM() *cli.Command { + return &cli.Command{ + Name: "update-pam", + Usage: "Update existing PAM authentication source", + Before: noDanglingArgs, + Action: newAuthService().updatePAM, + Flags: append(pamCLIFlags()[:1], append([]cli.Flag{idFlag()}, pamCLIFlags()[1:]...)...), + } +} + +func parsePAMConfig(_ context.Context, c *cli.Command) *pam.Source { + return &pam.Source{ + ServiceName: c.String("service-name"), + EmailDomain: c.String("email-domain"), + SkipLocalTwoFA: c.Bool("skip-local-2fa"), + } +} + +func (a *authService) addPAM(ctx context.Context, c *cli.Command) error { + ctx, cancel := installSignals(ctx) + defer cancel() + + if err := a.initDB(ctx); err != nil { + return err + } + + if !c.IsSet("name") || len(c.String("name")) == 0 { + return errors.New("name must be set") + } + if !c.IsSet("service-name") || len(c.String("service-name")) == 0 { + return errors.New("service-name must be set") + } + active := true + if c.IsSet("active") { + active = c.Bool("active") + } + + config := parsePAMConfig(ctx, c) + + return a.createAuthSource(ctx, &auth_model.Source{ + Type: auth_model.PAM, + Name: c.String("name"), + IsActive: active, + Cfg: config, + }) +} + +func (a *authService) updatePAM(ctx context.Context, c *cli.Command) error { + if !c.IsSet("id") { + return errors.New("--id flag is missing") + } + + ctx, cancel := installSignals(ctx) + defer cancel() + + if err := a.initDB(ctx); err != nil { + return err + } + + source, err := a.getAuthSource(ctx, c, auth_model.PAM) + if err != nil { + return err + } + + pamConfig := source.Cfg.(*pam.Source) + + if c.IsSet("name") { + source.Name = c.String("name") + } + + if c.IsSet("service-name") { + pamConfig.ServiceName = c.String("service-name") + } + + if c.IsSet("email-domain") { + pamConfig.EmailDomain = c.String("email-domain") + } + + if c.IsSet("skip-local-2fa") { + pamConfig.SkipLocalTwoFA = c.Bool("skip-local-2fa") + } + + if c.IsSet("active") { + source.IsActive = c.Bool("active") + } + + source.Cfg = pamConfig + + return a.updateAuthSource(ctx, source) +} diff --git a/cmd/admin_auth_pam_test.go b/cmd/admin_auth_pam_test.go new file mode 100644 index 0000000000..d14dfe790b --- /dev/null +++ b/cmd/admin_auth_pam_test.go @@ -0,0 +1,293 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "context" + "testing" + + "forgejo.org/models/auth" + "forgejo.org/modules/test" + "forgejo.org/services/auth/source/pam" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func TestPamService(t *testing.T) { + // Mock cli functions to do not exit on error + defer test.MockVariableValue(&cli.OsExiter, func(code int) {})() + + // Test cases + cases := []struct { + args []string + source *auth.Source + errMsg string + }{ + // case 0 + { + args: []string{ + "pam-test", + "--name", "Pam Service", + "--service-name", "myservice", + }, + source: &auth.Source{ + Type: auth.PAM, + Name: "Pam Service", + IsActive: true, + Cfg: &pam.Source{ + ServiceName: "myservice", + EmailDomain: "", + SkipLocalTwoFA: true, + }, + }, + }, + // case 1 + { + args: []string{ + "pam-test", + "--name", "Pam Service", + "--service-name", "myservice", + "--email-domain", "testdomain.org", + "--skip-local-2fa", + }, + source: &auth.Source{ + Type: auth.PAM, + Name: "Pam Service", + IsActive: true, + Cfg: &pam.Source{ + ServiceName: "myservice", + EmailDomain: "testdomain.org", + SkipLocalTwoFA: true, + }, + }, + }, + // case 2 + { + args: []string{ + "pam-test", + "--service-name", "myservice", + "--email-domain", "testdomain.org", + "--skip-local-2fa", "false", + "--active", "true", + }, + errMsg: "name must be set", + }, + // case 3 + { + args: []string{ + "pam-test", + "--name", "Pam Service", + "--email-domain", "testdomain.org", + "--skip-local-2fa", "false", + "--active", "true", + }, + errMsg: "service-name must be set", + }, + } + + for n, c := range cases { + // Mock functions. + var createdAuthSource *auth.Source + service := &authService{ + initDB: func(context.Context) error { + return nil + }, + createAuthSource: func(ctx context.Context, authSource *auth.Source) error { + createdAuthSource = authSource + return nil + }, + updateAuthSource: func(ctx context.Context, authSource *auth.Source) error { + assert.FailNow(t, "should not call updateAuthSource", "case: %d", n) + return nil + }, + getAuthSourceByID: func(ctx context.Context, id int64) (*auth.Source, error) { + assert.FailNow(t, "should not call getAuthSourceByID", "case: %d", n) + return nil, nil + }, + } + + // Create a copy of command to test + app := cli.Command{} + app.Flags = microcmdAuthAddPAM().Flags + app.Action = service.addPAM + + // Run it + err := app.Run(t.Context(), c.args) + if c.errMsg != "" { + assert.EqualError(t, err, c.errMsg, "case %d: error should match", n) + } else { + require.NoError(t, err, "case %d: should have no errors", n) + assert.Equal(t, c.source, createdAuthSource, "case %d: wrong authSource", n) + } + } +} + +func TestUpdatePAM(t *testing.T) { + // Mock cli functions to do not exit on error + defer test.MockVariableValue(&cli.OsExiter, func(code int) {})() + + // Test cases + cases := []struct { + args []string + id int64 + existingAuthSource *auth.Source + authSource *auth.Source + errMsg string + }{ + // case 0 + { + args: []string{ + "pam-test", + "--id", "23", + "--name", "PAM Service", + "--service-name", "myservice", + }, + id: 23, + existingAuthSource: &auth.Source{ + Type: auth.PAM, + IsActive: true, + Cfg: &pam.Source{}, + }, + authSource: &auth.Source{ + Type: auth.PAM, + Name: "PAM Service", + IsActive: true, + Cfg: &pam.Source{ + ServiceName: "myservice", + }, + }, + }, + // case 1 + { + args: []string{ + "pam-test", + "--id", "1", + }, + authSource: &auth.Source{ + Type: auth.PAM, + Cfg: &pam.Source{}, + }, + }, + // case 2 + { + args: []string{ + "pam-test", + "--id", "1", + "--name", "pam service", + }, + authSource: &auth.Source{ + Type: auth.PAM, + Name: "pam service", + Cfg: &pam.Source{}, + }, + }, + // case 3 + { + args: []string{ + "pam-test", + "--id", "1", + "--active=false", + }, + existingAuthSource: &auth.Source{ + Type: auth.PAM, + IsActive: true, + Cfg: &pam.Source{}, + }, + authSource: &auth.Source{ + Type: auth.PAM, + IsActive: false, + Cfg: &pam.Source{}, + }, + }, + // case 4 + { + args: []string{ + "pam-test", + "--id", "1", + "--service-name", "myservice", + }, + authSource: &auth.Source{ + Type: auth.PAM, + Cfg: &pam.Source{ + ServiceName: "myservice", + }, + }, + }, + // case 5 + { + args: []string{ + "pam-test", + "--id", "1", + "--skip-local-2fa=false", + }, + authSource: &auth.Source{ + Type: auth.PAM, + Cfg: &pam.Source{ + SkipLocalTwoFA: false, + }, + }, + }, + // case 6 + { + args: []string{ + "pam-test", + "--id", "1", + "--email-domain", "testdomain.org", + }, + authSource: &auth.Source{ + Type: auth.PAM, + Cfg: &pam.Source{ + EmailDomain: "testdomain.org", + }, + }, + }, + } + + for n, c := range cases { + // Mock functions. + var updatedAuthSource *auth.Source + service := &authService{ + initDB: func(context.Context) error { + return nil + }, + createAuthSource: func(ctx context.Context, authSource *auth.Source) error { + assert.FailNow(t, "should not call createAuthSource", "case: %d", n) + return nil + }, + updateAuthSource: func(ctx context.Context, authSource *auth.Source) error { + updatedAuthSource = authSource + return nil + }, + getAuthSourceByID: func(ctx context.Context, id int64) (*auth.Source, error) { + if c.id != 0 { + assert.Equal(t, c.id, id, "case %d: wrong id", n) + } + if c.existingAuthSource != nil { + return c.existingAuthSource, nil + } + return &auth.Source{ + Type: auth.PAM, + Cfg: &pam.Source{}, + }, nil + }, + } + + // Create a copy of command to test + app := cli.Command{} + app.Flags = microcmdAuthUpdatePAM().Flags + app.Action = service.updatePAM + + // Run it + err := app.Run(t.Context(), c.args) + if c.errMsg != "" { + assert.EqualError(t, err, c.errMsg, "case %d: error should match", n) + } else { + require.NoError(t, err, "case %d: should have no errors", n) + assert.Equal(t, c.authSource, updatedAuthSource, "case %d: wrong authSource", n) + } + } +}