From 5b73467d024dcc8d489709a6ee99d5fdf54136a6 Mon Sep 17 00:00:00 2001 From: Baptiste Daroussin Date: Fri, 19 Dec 2025 15:20:52 +0100 Subject: [PATCH] feat: allow to add pam source from command line (#10388) The forgejo admin command line allows to deal with all the propose auth mecanism but pam, this PR adds full support for adding and updating pam auth mecanism via the command line without limitation. Co-authored-by: Gusted Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10388 Reviewed-by: Gusted Co-authored-by: Baptiste Daroussin Co-committed-by: Baptiste Daroussin --- cmd/admin.go | 2 + cmd/admin_auth_pam.go | 145 ++++++++++++++++++ cmd/admin_auth_pam_test.go | 293 +++++++++++++++++++++++++++++++++++++ 3 files changed, 440 insertions(+) create mode 100644 cmd/admin_auth_pam.go create mode 100644 cmd/admin_auth_pam_test.go 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) + } + } +}