feat(auth): add local and OIDC management to tht
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/aritmolab/thothii/tools/tht/internal/authconfig"
|
||||||
"github.com/aritmolab/thothii/tools/tht/internal/backup"
|
"github.com/aritmolab/thothii/tools/tht/internal/backup"
|
||||||
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
||||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||||
@@ -22,6 +23,7 @@ import (
|
|||||||
"github.com/aritmolab/thothii/tools/tht/internal/output"
|
"github.com/aritmolab/thothii/tools/tht/internal/output"
|
||||||
"github.com/aritmolab/thothii/tools/tht/internal/pi"
|
"github.com/aritmolab/thothii/tools/tht/internal/pi"
|
||||||
"github.com/aritmolab/thothii/tools/tht/internal/project"
|
"github.com/aritmolab/thothii/tools/tht/internal/project"
|
||||||
|
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
||||||
"github.com/aritmolab/thothii/tools/tht/internal/serverops"
|
"github.com/aritmolab/thothii/tools/tht/internal/serverops"
|
||||||
"github.com/aritmolab/thothii/tools/tht/internal/service"
|
"github.com/aritmolab/thothii/tools/tht/internal/service"
|
||||||
"github.com/aritmolab/thothii/tools/tht/internal/setup"
|
"github.com/aritmolab/thothii/tools/tht/internal/setup"
|
||||||
@@ -38,6 +40,10 @@ Commands:
|
|||||||
setup [--configure-only] [--installation-id ID] [--profile local|server]
|
setup [--configure-only] [--installation-id ID] [--profile local|server]
|
||||||
Create or validate the local non-secret installation configuration.
|
Create or validate the local non-secret installation configuration.
|
||||||
version [--json] Show the host CLI build identity.
|
version [--json] Show the host CLI build identity.
|
||||||
|
auth configure --mode local|oidc ...
|
||||||
|
Configure local users or OIDC group mapping; see tht auth for exact options.
|
||||||
|
auth status [--json] Show the redacted authentication configuration status.
|
||||||
|
auth user ... Manage local users; unavailable for OIDC installations.
|
||||||
status Show the Compose service state.
|
status Show the Compose service state.
|
||||||
doctor [--json] Run non-mutating host, Compose, workflow, and Pi diagnostics.
|
doctor [--json] Run non-mutating host, Compose, workflow, and Pi diagnostics.
|
||||||
logs Show the latest 200 sanitized service log lines (bounded; no follow mode).
|
logs Show the latest 200 sanitized service log lines (bounded; no follow mode).
|
||||||
@@ -130,6 +136,12 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
|||||||
fmt.Fprintf(stderr, "tht: %s\n", output.Sanitize(err.Error(), nil))
|
fmt.Fprintf(stderr, "tht: %s\n", output.Sanitize(err.Error(), nil))
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
if command != "auth" || len(commandArgs) == 0 || commandArgs[0] != "configure" {
|
||||||
|
if err := safeio.ValidatePrivateDirectory(installation.AuthenticationDirectory()); err != nil {
|
||||||
|
fmt.Fprintln(stderr, "tht: authentication configuration directory is unavailable or unsafe")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
secretFiles, err := installation.SecretFiles()
|
secretFiles, err := installation.SecretFiles()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(stderr, "tht: installation secret declarations could not be read")
|
fmt.Fprintln(stderr, "tht: installation secret declarations could not be read")
|
||||||
@@ -144,6 +156,8 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
|||||||
runner := compose.NewRunner("")
|
runner := compose.NewRunner("")
|
||||||
var result compose.Result
|
var result compose.Result
|
||||||
switch command {
|
switch command {
|
||||||
|
case "auth":
|
||||||
|
return authconfig.Run(ctx, installation, commandArgs, os.Stdin, stdout, stderr)
|
||||||
case "status":
|
case "status":
|
||||||
if len(commandArgs) != 0 {
|
if len(commandArgs) != 0 {
|
||||||
return commandUsageError(stderr, "status does not accept arguments")
|
return commandUsageError(stderr, "status does not accept arguments")
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ func TestRootCommandIdentity(t *testing.T) {
|
|||||||
name: "help banner",
|
name: "help banner",
|
||||||
args: []string{"--help"},
|
args: []string{"--help"},
|
||||||
wantCode: 0,
|
wantCode: 0,
|
||||||
wantText: "Usage: tht ",
|
wantText: " auth configure",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "version path",
|
name: "version path",
|
||||||
@@ -1446,8 +1446,12 @@ func newCLIFixture(t *testing.T, envTemplate string) cliFixture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
envFile := filepath.Join(root, "installation.env")
|
envFile := filepath.Join(root, "installation.env")
|
||||||
|
authDirectory := filepath.Join(root, "auth")
|
||||||
|
if err := os.Mkdir(authDirectory, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
installationPath := filepath.Join(root, "thothii-installation.yaml")
|
installationPath := filepath.Join(root, "thothii-installation.yaml")
|
||||||
contents := "profile: local\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\n"
|
contents := "profile: local\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + authDirectory + "\n"
|
||||||
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
|
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -1522,6 +1526,7 @@ func (f cliFixture) setEnvironment(t *testing.T, values ...string) {
|
|||||||
|
|
||||||
func (f cliFixture) setEnvContents(t *testing.T, env string) {
|
func (f cliFixture) setEnvContents(t *testing.T, env string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
env += "THT_AUTH_CONFIG_ROOT=" + strconv.Quote(filepath.Join(f.root, "auth")) + "\n"
|
||||||
if err := os.WriteFile(f.envFile, []byte(env), 0o600); err != nil {
|
if err := os.WriteFile(f.envFile, []byte(env), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -1547,7 +1552,7 @@ func (f cliFixture) setProfile(t *testing.T, profile string) {
|
|||||||
if err := os.WriteFile(composePath, []byte("services: {}\n"), 0o600); err != nil {
|
if err := os.WriteFile(composePath, []byte("services: {}\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
contents := "profile: " + profile + "\nprojectDirectory: " + f.projectDirectory + "\nenvFile: " + f.envFile + "\n"
|
contents := "profile: " + profile + "\nprojectDirectory: " + f.projectDirectory + "\nenvFile: " + f.envFile + "\nauthentication:\n configDirectory: " + filepath.Join(f.root, "auth") + "\n"
|
||||||
if err := os.WriteFile(f.installationPath, []byte(contents), 0o600); err != nil {
|
if err := os.WriteFile(f.installationPath, []byte(contents), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ require (
|
|||||||
github.com/sirupsen/logrus v1.9.1
|
github.com/sirupsen/logrus v1.9.1
|
||||||
golang.org/x/crypto v0.55.0
|
golang.org/x/crypto v0.55.0
|
||||||
golang.org/x/sys v0.47.0
|
golang.org/x/sys v0.47.0
|
||||||
|
golang.org/x/term v0.45.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xi
|
|||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
|
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
@@ -0,0 +1,628 @@
|
|||||||
|
package authconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||||
|
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
||||||
|
"golang.org/x/term"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxPasswordFileBytes int64 = 1025
|
||||||
|
|
||||||
|
var errCommandRefused = errors.New("authentication command refused")
|
||||||
|
|
||||||
|
// Run implements the host-only authentication operator surface. It accepts password bytes only
|
||||||
|
// from an echo-free terminal or a bounded private file, and never writes them to either stream.
|
||||||
|
func Run(ctx context.Context, installation config.Installation, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||||
|
_ = ctx
|
||||||
|
if len(args) == 0 {
|
||||||
|
return authFailure(stderr, "auth requires a subcommand")
|
||||||
|
}
|
||||||
|
directory := installation.AuthenticationDirectory()
|
||||||
|
switch args[0] {
|
||||||
|
case "configure":
|
||||||
|
if err := configure(directory, args[1:], stdin, stderr); err != nil {
|
||||||
|
return authFailure(stderr, authMessage(err))
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
case "status":
|
||||||
|
if err := status(directory, args[1:], stdout); err != nil {
|
||||||
|
return authFailure(stderr, authMessage(err))
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
case "user":
|
||||||
|
if err := user(directory, args[1:], stdin, stdout, stderr); err != nil {
|
||||||
|
return authFailure(stderr, authMessage(err))
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
case "check":
|
||||||
|
return authFailure(stderr, "auth check is not available in this release")
|
||||||
|
default:
|
||||||
|
return authFailure(stderr, "unknown auth subcommand")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authFailure(stderr io.Writer, message string) int {
|
||||||
|
fmt.Fprintf(stderr, "tht: %s\n", message)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
func authMessage(err error) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errPasswordFileRequired):
|
||||||
|
return "--password-file is required when standard input is not an interactive terminal"
|
||||||
|
case errors.Is(err, errExistingConfiguration):
|
||||||
|
return "authentication is already configured; refusing to overwrite it"
|
||||||
|
case errors.Is(err, errOIDCUsers):
|
||||||
|
return "local user commands are unavailable while OIDC authentication is configured"
|
||||||
|
case errors.Is(err, errLastAdministrator):
|
||||||
|
return "refusing to remove or disable the last enabled administrator"
|
||||||
|
case errors.Is(err, errAmbiguousGroups):
|
||||||
|
return "OIDC user and administrator groups must be different"
|
||||||
|
case errors.Is(err, errCommandRefused):
|
||||||
|
return "authentication command refused"
|
||||||
|
default:
|
||||||
|
return "authentication configuration is unavailable or invalid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
errPasswordFileRequired = errors.New("password file required")
|
||||||
|
errExistingConfiguration = errors.New("existing configuration")
|
||||||
|
errOIDCUsers = errors.New("OIDC user commands")
|
||||||
|
errLastAdministrator = errors.New("last administrator")
|
||||||
|
errAmbiguousGroups = errors.New("ambiguous groups")
|
||||||
|
)
|
||||||
|
|
||||||
|
type configureRequest struct {
|
||||||
|
mode, publicURL, adminUser, adminDisplayName, passwordFile string
|
||||||
|
issuer, clientID, authentikBaseURL, userGroup, adminGroup string
|
||||||
|
}
|
||||||
|
|
||||||
|
func configure(directory string, args []string, stdin io.Reader, stderr io.Writer) error {
|
||||||
|
request, err := parseConfigure(args)
|
||||||
|
if err != nil {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
if err := safeio.EnsurePrivateDirectory(directory); err != nil {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
if _, _, err := Load(directory); err == nil {
|
||||||
|
return errExistingConfiguration
|
||||||
|
}
|
||||||
|
for _, name := range []string{authFileName, usersFileName} {
|
||||||
|
if _, err := os.Lstat(filepath.Join(directory, name)); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if request.mode == "oidc" {
|
||||||
|
if request.userGroup == request.adminGroup {
|
||||||
|
return errAmbiguousGroups
|
||||||
|
}
|
||||||
|
return writeInitialOIDC(directory, request)
|
||||||
|
}
|
||||||
|
if request.adminUser == "" {
|
||||||
|
if err := promptLocalConfigure(&request, stdin, stderr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
password, err := readPassword(request.passwordFile, stdin, stderr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer zero(password)
|
||||||
|
hash, err := HashPassword(password, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
initialUser, err := newUser(request.adminUser, request.adminDisplayName, hash, []Role{RoleAdmin})
|
||||||
|
if err != nil {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
return writeInitialLocal(directory, request.publicURL, initialUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseConfigure(args []string) (configureRequest, error) {
|
||||||
|
request := configureRequest{}
|
||||||
|
values := map[string]*string{
|
||||||
|
"--mode": &request.mode, "--public-url": &request.publicURL, "--admin-user": &request.adminUser,
|
||||||
|
"--admin-display-name": &request.adminDisplayName, "--password-file": &request.passwordFile,
|
||||||
|
"--issuer": &request.issuer, "--client-id": &request.clientID, "--authentik-base-url": &request.authentikBaseURL,
|
||||||
|
"--user-group": &request.userGroup, "--admin-group": &request.adminGroup,
|
||||||
|
}
|
||||||
|
for len(args) > 0 {
|
||||||
|
option := args[0]
|
||||||
|
args = args[1:]
|
||||||
|
target, exists := values[option]
|
||||||
|
if !exists || len(args) == 0 || *target != "" {
|
||||||
|
return configureRequest{}, errCommandRefused
|
||||||
|
}
|
||||||
|
*target, args = args[0], args[1:]
|
||||||
|
}
|
||||||
|
if request.mode != "local" && request.mode != "oidc" || !validPublicURL(request.publicURL) {
|
||||||
|
return configureRequest{}, errCommandRefused
|
||||||
|
}
|
||||||
|
if request.mode == "local" {
|
||||||
|
if request.issuer != "" || request.clientID != "" || request.authentikBaseURL != "" || request.userGroup != "" || request.adminGroup != "" {
|
||||||
|
return configureRequest{}, errCommandRefused
|
||||||
|
}
|
||||||
|
if request.adminUser != "" && request.passwordFile == "" { // TTY supplies the password interactively.
|
||||||
|
return request, nil
|
||||||
|
}
|
||||||
|
return request, nil
|
||||||
|
}
|
||||||
|
if request.adminUser != "" || request.adminDisplayName != "" || request.passwordFile != "" || request.issuer == "" || request.clientID == "" || request.authentikBaseURL == "" || request.userGroup == "" || request.adminGroup == "" || !validOIDCURL(request.issuer) || !validHTTPSURL(request.authentikBaseURL) || (!isHTTPS(request.publicURL) && !isLoopbackURL(request.publicURL)) {
|
||||||
|
return configureRequest{}, errCommandRefused
|
||||||
|
}
|
||||||
|
return request, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptLocalConfigure(request *configureRequest, stdin io.Reader, stderr io.Writer) error {
|
||||||
|
file, ok := stdin.(*os.File)
|
||||||
|
if !ok || !term.IsTerminal(int(file.Fd())) {
|
||||||
|
return errPasswordFileRequired
|
||||||
|
}
|
||||||
|
reader := bufio.NewReader(file)
|
||||||
|
username, err := promptLine(reader, stderr, "Initial administrator username: ")
|
||||||
|
if err != nil || username == "" {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
displayName, err := promptLine(reader, stderr, "Initial administrator display name (optional): ")
|
||||||
|
if err != nil {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
request.adminUser, request.adminDisplayName = username, displayName
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptLine(reader *bufio.Reader, stderr io.Writer, prompt string) (string, error) {
|
||||||
|
fmt.Fprint(stderr, prompt)
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(line), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeInitialLocal(directory, publicURL string, user User) error {
|
||||||
|
registryBytes, err := yaml.Marshal(Registry{Version: 1, Users: []User{user}})
|
||||||
|
if err != nil {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
configBytes, err := yaml.Marshal(Config{Version: 1, Mode: "local", PublicURL: publicURL, Session: defaultSession(), Local: LocalConfig{UsersFile: usersFileName}})
|
||||||
|
if err != nil {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
if err := safeio.WriteCanonicalNewFile(filepath.Join(directory, usersFileName), append(registryBytes, '\n'), 0o600); err != nil {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
if err := safeio.WriteCanonicalNewFile(filepath.Join(directory, authFileName), append(configBytes, '\n'), 0o600); err != nil {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeInitialOIDC(directory string, request configureRequest) error {
|
||||||
|
configuration := Config{
|
||||||
|
Version: 1, Mode: "oidc", PublicURL: request.publicURL, Session: defaultSession(),
|
||||||
|
OIDC: OIDCConfig{Issuer: request.issuer, ClientID: request.clientID, ClientSecretRef: "THT_OIDC_CLIENT_SECRET", Scopes: []string{"openid", "profile", "email"}, GroupsClaim: "groups"},
|
||||||
|
GroupCatalog: GroupCatalogConfig{Driver: "authentik", BaseURL: request.authentikBaseURL, APITokenRef: "THT_AUTHENTIK_API_TOKEN"},
|
||||||
|
Authorization: AuthorizationConfig{GroupRoles: map[string][]Role{request.userGroup: {RoleUser}, request.adminGroup: {RoleAdmin}}},
|
||||||
|
}
|
||||||
|
contents, err := yaml.Marshal(configuration)
|
||||||
|
if err != nil {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
if err := safeio.WriteCanonicalNewFile(filepath.Join(directory, authFileName), append(contents, '\n'), 0o600); err != nil {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultSession() SessionConfig {
|
||||||
|
return SessionConfig{RegularTTLSeconds: 43200, RegularIdleSeconds: 7200, RememberTTLSeconds: 2592000, RememberIdleSeconds: 604800, OIDCTTLSeconds: 28800}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPassword(path string, stdin io.Reader, stderr io.Writer) ([]byte, error) {
|
||||||
|
if path != "" {
|
||||||
|
if err := safeio.ValidatePrivateRegular(path); err != nil {
|
||||||
|
return nil, errCommandRefused
|
||||||
|
}
|
||||||
|
password, err := safeio.ReadCanonicalRegular(path, maxPasswordFileBytes)
|
||||||
|
if err != nil || len(password) > passwordMaxBytes+1 {
|
||||||
|
return nil, errCommandRefused
|
||||||
|
}
|
||||||
|
password = trimOneNewline(password)
|
||||||
|
return password, validatePasswordInput(password)
|
||||||
|
}
|
||||||
|
file, ok := stdin.(*os.File)
|
||||||
|
if !ok || !term.IsTerminal(int(file.Fd())) {
|
||||||
|
return nil, errPasswordFileRequired
|
||||||
|
}
|
||||||
|
fmt.Fprint(stderr, "Password: ")
|
||||||
|
password, err := term.ReadPassword(int(file.Fd()))
|
||||||
|
fmt.Fprintln(stderr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errCommandRefused
|
||||||
|
}
|
||||||
|
fmt.Fprint(stderr, "Confirm password: ")
|
||||||
|
confirmation, err := term.ReadPassword(int(file.Fd()))
|
||||||
|
fmt.Fprintln(stderr)
|
||||||
|
if err != nil {
|
||||||
|
zero(password)
|
||||||
|
return nil, errCommandRefused
|
||||||
|
}
|
||||||
|
matched := string(password) == string(confirmation)
|
||||||
|
zero(confirmation)
|
||||||
|
if !matched {
|
||||||
|
zero(password)
|
||||||
|
return nil, errCommandRefused
|
||||||
|
}
|
||||||
|
return password, validatePasswordInput(password)
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimOneNewline(value []byte) []byte {
|
||||||
|
if bytes.HasSuffix(value, []byte("\r\n")) {
|
||||||
|
return value[:len(value)-2]
|
||||||
|
}
|
||||||
|
if bytes.HasSuffix(value, []byte("\n")) {
|
||||||
|
return value[:len(value)-1]
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePasswordInput(password []byte) error {
|
||||||
|
if !validPassword(password) {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func zero(value []byte) {
|
||||||
|
for index := range value {
|
||||||
|
value[index] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusResult struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
PublicURL string `json:"publicUrl"`
|
||||||
|
UserCounts map[string]int `json:"userCounts"`
|
||||||
|
Revision string `json:"configRevision"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func status(directory string, args []string, stdout io.Writer) error {
|
||||||
|
jsonMode := len(args) == 1 && args[0] == "--json"
|
||||||
|
if len(args) != 0 && !jsonMode {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
configuration, registry, err := Load(directory)
|
||||||
|
if err != nil {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
counts := map[string]int{"user": 0, "admin": 0}
|
||||||
|
for _, user := range registry.Users {
|
||||||
|
for _, role := range user.Roles {
|
||||||
|
counts[string(role)]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
revision, err := configRevision(directory, configuration.Mode)
|
||||||
|
if err != nil {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
result := statusResult{Mode: configuration.Mode, PublicURL: configuration.PublicURL, UserCounts: counts, Revision: revision}
|
||||||
|
if jsonMode {
|
||||||
|
encoder := json.NewEncoder(stdout)
|
||||||
|
encoder.SetEscapeHTML(false)
|
||||||
|
return encoder.Encode(result)
|
||||||
|
}
|
||||||
|
_, err = fmt.Fprintf(stdout, "Authentication mode: %s\nPublic URL: %s\nUsers: user=%d admin=%d\nConfig revision: %s\n", result.Mode, result.PublicURL, counts["user"], counts["admin"], result.Revision)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func configRevision(directory, mode string) (string, error) {
|
||||||
|
auth, err := safeio.ReadCanonicalRegular(filepath.Join(directory, authFileName), maxYAMLBytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sum := sha256.New()
|
||||||
|
_, _ = sum.Write(auth)
|
||||||
|
if mode == "local" {
|
||||||
|
users, err := safeio.ReadCanonicalRegular(filepath.Join(directory, usersFileName), maxYAMLBytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, _ = sum.Write(users)
|
||||||
|
}
|
||||||
|
return "sha256:" + hex.EncodeToString(sum.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func user(directory string, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
configuration, _, err := Load(directory)
|
||||||
|
if err != nil {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
if configuration.Mode != "local" {
|
||||||
|
return errOIDCUsers
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "list":
|
||||||
|
return listUsers(directory, args[1:], stdout)
|
||||||
|
case "add":
|
||||||
|
return addUser(directory, args[1:], stdin, stderr)
|
||||||
|
case "set-password":
|
||||||
|
return setPassword(directory, args[1:], stdin, stderr)
|
||||||
|
case "enable", "disable":
|
||||||
|
return setEnabled(directory, args[0] == "enable", args[1:])
|
||||||
|
case "grant", "revoke":
|
||||||
|
return changeRole(directory, args[0] == "grant", args[1:])
|
||||||
|
case "logout-all":
|
||||||
|
return logoutAll(directory, args[1:])
|
||||||
|
default:
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listUsers(directory string, args []string, stdout io.Writer) error {
|
||||||
|
jsonMode := len(args) == 1 && args[0] == "--json"
|
||||||
|
if len(args) != 0 && !jsonMode {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
_, registry, err := Load(directory)
|
||||||
|
if err != nil {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
if jsonMode {
|
||||||
|
return json.NewEncoder(stdout).Encode(struct {
|
||||||
|
Users []User `json:"users"`
|
||||||
|
}{Users: registry.Users})
|
||||||
|
}
|
||||||
|
for _, item := range registry.Users {
|
||||||
|
if _, err := fmt.Fprintf(stdout, "%s\t%s\tenabled=%t\troles=%s\n", item.Username, item.DisplayName, item.Enabled, strings.Join(rolesToStrings(item.Roles), ",")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addUser(directory string, args []string, stdin io.Reader, stderr io.Writer) error {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
username, request, err := parseUserRolePassword(args)
|
||||||
|
if err != nil {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
password, err := readPassword(request.passwordFile, stdin, stderr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer zero(password)
|
||||||
|
hash, err := HashPassword(password, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
return MutateUsers(directory, func(registry *Registry) error {
|
||||||
|
if registry.FindByUsername(username) != nil {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
item, err := newUser(username, request.displayName, hash, []Role{request.role})
|
||||||
|
if err != nil {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
registry.Users = append(registry.Users, item)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type userRequest struct {
|
||||||
|
role Role
|
||||||
|
displayName, passwordFile string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUserRolePassword(args []string) (string, userRequest, error) {
|
||||||
|
username := args[0]
|
||||||
|
args = args[1:]
|
||||||
|
request := userRequest{}
|
||||||
|
for len(args) > 0 {
|
||||||
|
option := args[0]
|
||||||
|
args = args[1:]
|
||||||
|
if len(args) == 0 {
|
||||||
|
return "", userRequest{}, errCommandRefused
|
||||||
|
}
|
||||||
|
value := args[0]
|
||||||
|
args = args[1:]
|
||||||
|
switch option {
|
||||||
|
case "--role":
|
||||||
|
if request.role != "" {
|
||||||
|
return "", userRequest{}, errCommandRefused
|
||||||
|
}
|
||||||
|
request.role = Role(value)
|
||||||
|
case "--display-name":
|
||||||
|
if request.displayName != "" {
|
||||||
|
return "", userRequest{}, errCommandRefused
|
||||||
|
}
|
||||||
|
request.displayName = value
|
||||||
|
case "--password-file":
|
||||||
|
if request.passwordFile != "" {
|
||||||
|
return "", userRequest{}, errCommandRefused
|
||||||
|
}
|
||||||
|
request.passwordFile = value
|
||||||
|
default:
|
||||||
|
return "", userRequest{}, errCommandRefused
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !usernamePattern.MatchString(username) || (request.role != RoleUser && request.role != RoleAdmin) {
|
||||||
|
return "", userRequest{}, errCommandRefused
|
||||||
|
}
|
||||||
|
return username, request, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPassword(directory string, args []string, stdin io.Reader, stderr io.Writer) error {
|
||||||
|
if len(args) < 1 || !usernamePattern.MatchString(args[0]) {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
username := args[0]
|
||||||
|
passwordFile, err := parsePasswordFile(args[1:])
|
||||||
|
if err != nil {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
password, err := readPassword(passwordFile, stdin, stderr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer zero(password)
|
||||||
|
hash, err := HashPassword(password, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
return MutateUsers(directory, func(registry *Registry) error {
|
||||||
|
item := registry.FindByUsername(username)
|
||||||
|
if item == nil {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
item.PasswordHash = hash
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePasswordFile(args []string) (string, error) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if len(args) != 2 || args[0] != "--password-file" || args[1] == "" {
|
||||||
|
return "", errCommandRefused
|
||||||
|
}
|
||||||
|
return args[1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setEnabled(directory string, enabled bool, args []string) error {
|
||||||
|
if len(args) != 1 || !usernamePattern.MatchString(args[0]) {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
return MutateUsers(directory, func(registry *Registry) error {
|
||||||
|
item := registry.FindByUsername(args[0])
|
||||||
|
if item == nil {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
if !enabled && item.Enabled && hasRole(item.Roles, RoleAdmin) && enabledAdminCount(*registry) == 1 {
|
||||||
|
return errLastAdministrator
|
||||||
|
}
|
||||||
|
item.Enabled = enabled
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func changeRole(directory string, grant bool, args []string) error {
|
||||||
|
if len(args) != 3 || !usernamePattern.MatchString(args[0]) || args[1] != "--role" || (args[2] != string(RoleUser) && args[2] != string(RoleAdmin)) {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
role := Role(args[2])
|
||||||
|
return MutateUsers(directory, func(registry *Registry) error {
|
||||||
|
item := registry.FindByUsername(args[0])
|
||||||
|
if item == nil {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
if grant {
|
||||||
|
if hasRole(item.Roles, role) {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
item.Roles = append(item.Roles, role)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !hasRole(item.Roles, role) || len(item.Roles) == 1 {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
if role == RoleAdmin && item.Enabled && enabledAdminCount(*registry) == 1 {
|
||||||
|
return errLastAdministrator
|
||||||
|
}
|
||||||
|
item.Roles = withoutRole(item.Roles, role)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func logoutAll(directory string, args []string) error {
|
||||||
|
if len(args) != 2 || !usernamePattern.MatchString(args[0]) || args[1] != "--yes" {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
return MutateUsers(directory, func(registry *Registry) error {
|
||||||
|
item := registry.FindByUsername(args[0])
|
||||||
|
if item == nil || item.AuthRevision == ^uint64(0) {
|
||||||
|
return errCommandRefused
|
||||||
|
}
|
||||||
|
item.AuthRevision++
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func enabledAdminCount(registry Registry) int {
|
||||||
|
count := 0
|
||||||
|
for _, item := range registry.Users {
|
||||||
|
if item.Enabled && hasRole(item.Roles, RoleAdmin) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func withoutRole(roles []Role, unwanted Role) []Role {
|
||||||
|
result := make([]Role, 0, len(roles)-1)
|
||||||
|
for _, role := range roles {
|
||||||
|
if role != unwanted {
|
||||||
|
result = append(result, role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func rolesToStrings(roles []Role) []string {
|
||||||
|
values := make([]string, len(roles))
|
||||||
|
for index, role := range roles {
|
||||||
|
values[index] = string(role)
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func validPublicURL(value string) bool {
|
||||||
|
parsed, err := url.Parse(value)
|
||||||
|
return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != "" && parsed.User == nil && parsed.Path == "" && parsed.RawQuery == "" && parsed.Fragment == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func validOIDCURL(value string) bool {
|
||||||
|
parsed, err := url.Parse(value)
|
||||||
|
return err == nil && parsed.Scheme == "https" && parsed.Host != "" && parsed.User == nil && parsed.RawQuery == "" && parsed.Fragment == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func validHTTPSURL(value string) bool {
|
||||||
|
return validOIDCURL(value) && strings.TrimSuffix(value, "/") == value
|
||||||
|
}
|
||||||
|
func isHTTPS(value string) bool {
|
||||||
|
parsed, err := url.Parse(value)
|
||||||
|
return err == nil && parsed.Scheme == "https"
|
||||||
|
}
|
||||||
|
func isLoopbackURL(value string) bool {
|
||||||
|
parsed, err := url.Parse(value)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
host := parsed.Hostname()
|
||||||
|
return host == "localhost" || net.ParseIP(host) != nil && net.ParseIP(host).IsLoopback()
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package authconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunConfiguresLocalRegistryAndRedactsStatusJSON(t *testing.T) {
|
||||||
|
directory := newAuthDirectory(t)
|
||||||
|
passwordFile := writePasswordFile(t, "this is a local test password\n")
|
||||||
|
installation := authInstallation(directory)
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if code := Run(context.Background(), installation, []string{
|
||||||
|
"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080",
|
||||||
|
"--admin-user", "admin", "--admin-display-name", "Local administrator", "--password-file", passwordFile,
|
||||||
|
}, strings.NewReader(""), &stdout, &stderr); code != 0 {
|
||||||
|
t.Fatalf("local configure = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
if stdout.Len() != 0 || stderr.Len() != 0 {
|
||||||
|
t.Fatalf("local configure wrote machine output: stdout=%q stderr=%q", stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout.Reset()
|
||||||
|
if code := Run(context.Background(), installation, []string{"status", "--json"}, strings.NewReader(""), &stdout, &stderr); code != 0 {
|
||||||
|
t.Fatalf("status = %d, stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
var status struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
PublicURL string `json:"publicUrl"`
|
||||||
|
UserCounts map[string]int `json:"userCounts"`
|
||||||
|
Revision string `json:"configRevision"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &status); err != nil {
|
||||||
|
t.Fatalf("status stdout is not pristine JSON: %q: %v", stdout.String(), err)
|
||||||
|
}
|
||||||
|
if status.Mode != "local" || status.PublicURL != "http://127.0.0.1:8080" || status.UserCounts["admin"] != 1 || status.Revision == "" {
|
||||||
|
t.Fatalf("status = %#v", status)
|
||||||
|
}
|
||||||
|
if strings.Contains(stdout.String()+stderr.String(), "this is a local test password") || strings.Contains(stdout.String()+stderr.String(), "$argon2id$") {
|
||||||
|
t.Fatalf("status exposed password material: stdout=%q stderr=%q", stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunConfiguresOIDCFixedGroupsAndRefusesIdenticalGroups(t *testing.T) {
|
||||||
|
for name, test := range map[string]struct {
|
||||||
|
args []string
|
||||||
|
wantMode string
|
||||||
|
}{
|
||||||
|
"configured": {
|
||||||
|
args: []string{"configure", "--mode", "oidc", "--public-url", "https://thothii.example.org", "--issuer", "https://authentik.example.org/application/o/thothii/", "--client-id", "thothii", "--authentik-base-url", "https://authentik.example.org", "--user-group", "TOT Users", "--admin-group", "TOT Admin"},
|
||||||
|
wantMode: "oidc",
|
||||||
|
},
|
||||||
|
"ambiguous groups": {
|
||||||
|
args: []string{"configure", "--mode", "oidc", "--public-url", "https://thothii.example.org", "--issuer", "https://authentik.example.org/application/o/thothii/", "--client-id", "thothii", "--authentik-base-url", "https://authentik.example.org", "--user-group", "same", "--admin-group", "same"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
directory := newAuthDirectory(t)
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
code := Run(context.Background(), authInstallation(directory), test.args, strings.NewReader(""), &stdout, &stderr)
|
||||||
|
if test.wantMode == "" {
|
||||||
|
if code == 0 || strings.Contains(stdout.String()+stderr.String(), "same") && strings.Contains(stdout.String()+stderr.String(), "secret") {
|
||||||
|
t.Fatalf("ambiguous groups accepted or exposed unsafe detail: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("OIDC configure = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
config, _, err := Load(directory)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if config.Mode != test.wantMode || config.Authorization.GroupRoles["TOT Users"][0] != RoleUser || config.Authorization.GroupRoles["TOT Admin"][0] != RoleAdmin {
|
||||||
|
t.Fatalf("OIDC config = %#v", config)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunMutatesLocalUsersAndPreservesLastAdministrator(t *testing.T) {
|
||||||
|
directory := newAuthDirectory(t)
|
||||||
|
installation := authInstallation(directory)
|
||||||
|
adminPassword := writePasswordFile(t, "initial administrator password\n")
|
||||||
|
userPassword := writePasswordFile(t, "ordinary user password one\n")
|
||||||
|
if code := Run(context.Background(), installation, []string{"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin", "--password-file", adminPassword}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 {
|
||||||
|
t.Fatalf("configure = %d", code)
|
||||||
|
}
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"user", "add", "operator", "--role", "user", "--display-name", "Operator", "--password-file", userPassword},
|
||||||
|
{"user", "set-password", "operator", "--password-file", adminPassword},
|
||||||
|
{"user", "grant", "operator", "--role", "admin"},
|
||||||
|
{"user", "revoke", "operator", "--role", "admin"},
|
||||||
|
{"user", "disable", "operator"},
|
||||||
|
{"user", "enable", "operator"},
|
||||||
|
} {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if code := Run(context.Background(), installation, args, strings.NewReader(""), &stdout, &stderr); code != 0 {
|
||||||
|
t.Fatalf("%v = %d stdout=%q stderr=%q", args, code, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(stdout.String()+stderr.String(), "initial administrator password") || strings.Contains(stdout.String()+stderr.String(), "$argon2id$") {
|
||||||
|
t.Fatalf("%v exposed password material", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if code := Run(context.Background(), installation, []string{"user", "disable", "admin"}, strings.NewReader(""), &stdout, &stderr); code == 0 {
|
||||||
|
t.Fatal("last administrator disable succeeded")
|
||||||
|
}
|
||||||
|
if code := Run(context.Background(), installation, []string{"user", "revoke", "admin", "--role", "admin"}, strings.NewReader(""), &stdout, &stderr); code == 0 {
|
||||||
|
t.Fatal("last administrator revoke succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunLogoutAllIncrementsRevisionAndUserCommandsRefuseOIDC(t *testing.T) {
|
||||||
|
directory := newAuthDirectory(t)
|
||||||
|
installation := authInstallation(directory)
|
||||||
|
passwordFile := writePasswordFile(t, "initial administrator password\n")
|
||||||
|
if code := Run(context.Background(), installation, []string{"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin", "--password-file", passwordFile}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 {
|
||||||
|
t.Fatalf("configure = %d", code)
|
||||||
|
}
|
||||||
|
if code := Run(context.Background(), installation, []string{"user", "logout-all", "admin", "--yes"}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 {
|
||||||
|
t.Fatalf("logout all = %d", code)
|
||||||
|
}
|
||||||
|
_, registry, err := Load(directory)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if registry.Users[0].AuthRevision != 2 {
|
||||||
|
t.Fatalf("logout-all revision = %d, want 2", registry.Users[0].AuthRevision)
|
||||||
|
}
|
||||||
|
|
||||||
|
oidcDirectory := newAuthDirectory(t)
|
||||||
|
if code := Run(context.Background(), authInstallation(oidcDirectory), []string{"configure", "--mode", "oidc", "--public-url", "https://thothii.example.org", "--issuer", "https://authentik.example.org/application/o/thothii/", "--client-id", "thothii", "--authentik-base-url", "https://authentik.example.org", "--user-group", "TOT Users", "--admin-group", "TOT Admin"}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 {
|
||||||
|
t.Fatalf("OIDC configure = %d", code)
|
||||||
|
}
|
||||||
|
if code := Run(context.Background(), authInstallation(oidcDirectory), []string{"user", "list", "--json"}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code == 0 {
|
||||||
|
t.Fatal("OIDC user command succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunRefusesNonTTYPasswordsWithoutPasswordFile(t *testing.T) {
|
||||||
|
directory := newAuthDirectory(t)
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
code := Run(context.Background(), authInstallation(directory), []string{"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin"}, strings.NewReader("not a password source"), &stdout, &stderr)
|
||||||
|
if code == 0 || !strings.Contains(stderr.String(), "--password-file") {
|
||||||
|
t.Fatalf("non-TTY password source was accepted: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePasswordFile(t *testing.T, password string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(newAuthDirectory(t), "password")
|
||||||
|
if err := os.Mkdir(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(password), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func authInstallation(directory string) config.Installation {
|
||||||
|
installation := config.Installation{}
|
||||||
|
installation.Authentication.ConfigDirectory = directory
|
||||||
|
return installation
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAuthDirectory(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
root, err := filepath.EvalSymlinks(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return filepath.Join(root, "auth")
|
||||||
|
}
|
||||||
|
|
||||||
|
type ioDiscard struct{}
|
||||||
|
|
||||||
|
func (ioDiscard) Write(contents []byte) (int, error) { return len(contents), nil }
|
||||||
@@ -37,10 +37,10 @@ type Config struct {
|
|||||||
Mode string `yaml:"mode" json:"mode"`
|
Mode string `yaml:"mode" json:"mode"`
|
||||||
PublicURL string `yaml:"publicUrl" json:"publicUrl"`
|
PublicURL string `yaml:"publicUrl" json:"publicUrl"`
|
||||||
Session SessionConfig `yaml:"session" json:"session"`
|
Session SessionConfig `yaml:"session" json:"session"`
|
||||||
Local LocalConfig `yaml:"local" json:"local"`
|
Local LocalConfig `yaml:"local,omitempty" json:"local,omitempty"`
|
||||||
OIDC OIDCConfig `yaml:"oidc" json:"oidc"`
|
OIDC OIDCConfig `yaml:"oidc,omitempty" json:"oidc,omitempty"`
|
||||||
GroupCatalog GroupCatalogConfig `yaml:"groupCatalog" json:"groupCatalog"`
|
GroupCatalog GroupCatalogConfig `yaml:"groupCatalog,omitempty" json:"groupCatalog,omitempty"`
|
||||||
Authorization AuthorizationConfig `yaml:"authorization" json:"authorization"`
|
Authorization AuthorizationConfig `yaml:"authorization,omitempty" json:"authorization,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionConfig struct {
|
type SessionConfig struct {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -188,12 +189,17 @@ func writeDiscoverableInstallation(t *testing.T, projectRoot, directory string)
|
|||||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
envFile := filepath.Join(directory, "operator.env")
|
physicalDirectory, err := filepath.EvalSymlinks(directory)
|
||||||
if err := os.WriteFile(envFile, nil, 0o600); err != nil {
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
envFile := filepath.Join(physicalDirectory, "operator.env")
|
||||||
|
authDirectory := filepath.Join(physicalDirectory, "auth")
|
||||||
|
if err := os.WriteFile(envFile, []byte("SAFE_VALUE=1\nTHT_AUTH_CONFIG_ROOT="+strconv.Quote(authDirectory)+"\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
installationPath := filepath.Join(directory, "thothii-installation.yaml")
|
installationPath := filepath.Join(directory, "thothii-installation.yaml")
|
||||||
contents := "profile: local\nprojectDirectory: " + projectRoot + "\nenvFile: " + envFile + "\n"
|
contents := "profile: local\nprojectDirectory: " + projectRoot + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + authDirectory + "\n"
|
||||||
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
|
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,9 +34,14 @@ type descriptor struct {
|
|||||||
ProjectDirectory string `yaml:"projectDirectory"`
|
ProjectDirectory string `yaml:"projectDirectory"`
|
||||||
EnvFile string `yaml:"envFile"`
|
EnvFile string `yaml:"envFile"`
|
||||||
WorkspaceRepository workspaceRepositoryDescriptor `yaml:"workspaceRepository"`
|
WorkspaceRepository workspaceRepositoryDescriptor `yaml:"workspaceRepository"`
|
||||||
|
Authentication authenticationDescriptor `yaml:"authentication"`
|
||||||
Overrides []string `yaml:"overrides"`
|
Overrides []string `yaml:"overrides"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type authenticationDescriptor struct {
|
||||||
|
ConfigDirectory string `yaml:"configDirectory"`
|
||||||
|
}
|
||||||
|
|
||||||
type workspaceRepositoryDescriptor struct {
|
type workspaceRepositoryDescriptor struct {
|
||||||
Remote string `yaml:"remote"`
|
Remote string `yaml:"remote"`
|
||||||
Branch string `yaml:"branch"`
|
Branch string `yaml:"branch"`
|
||||||
@@ -50,6 +55,11 @@ type WorkspaceRepository struct {
|
|||||||
Access string
|
Access string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authentication is the non-secret filesystem location for the installation auth configuration.
|
||||||
|
type Authentication struct {
|
||||||
|
ConfigDirectory string
|
||||||
|
}
|
||||||
|
|
||||||
// Installation is a validated local Compose installation. It intentionally contains paths, not
|
// Installation is a validated local Compose installation. It intentionally contains paths, not
|
||||||
// environment values or secret content.
|
// environment values or secret content.
|
||||||
type Installation struct {
|
type Installation struct {
|
||||||
@@ -58,6 +68,7 @@ type Installation struct {
|
|||||||
ProjectDirectory string
|
ProjectDirectory string
|
||||||
EnvFile string
|
EnvFile string
|
||||||
WorkspaceRepository WorkspaceRepository
|
WorkspaceRepository WorkspaceRepository
|
||||||
|
Authentication Authentication
|
||||||
Overrides []string
|
Overrides []string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +110,9 @@ func Load(path string) (Installation, error) {
|
|||||||
if err := requireRegularFile(raw.EnvFile, "envFile"); err != nil {
|
if err := requireRegularFile(raw.EnvFile, "envFile"); err != nil {
|
||||||
return Installation{}, err
|
return Installation{}, err
|
||||||
}
|
}
|
||||||
|
if err := safeio.ValidateCanonicalPath(raw.Authentication.ConfigDirectory); err != nil {
|
||||||
|
return Installation{}, errors.New("authentication.configDirectory must be an absolute canonical path")
|
||||||
|
}
|
||||||
|
|
||||||
installation := Installation{
|
installation := Installation{
|
||||||
Path: path,
|
Path: path,
|
||||||
@@ -110,7 +124,15 @@ func Load(path string) (Installation, error) {
|
|||||||
Branch: raw.WorkspaceRepository.Branch,
|
Branch: raw.WorkspaceRepository.Branch,
|
||||||
Access: raw.WorkspaceRepository.Access,
|
Access: raw.WorkspaceRepository.Access,
|
||||||
},
|
},
|
||||||
Overrides: make([]string, 0, len(raw.Overrides)),
|
Authentication: Authentication{ConfigDirectory: raw.Authentication.ConfigDirectory},
|
||||||
|
Overrides: make([]string, 0, len(raw.Overrides)),
|
||||||
|
}
|
||||||
|
values, err := installation.environmentValues()
|
||||||
|
if err != nil {
|
||||||
|
return Installation{}, errors.New("installation secret declarations could not be read")
|
||||||
|
}
|
||||||
|
if values["THT_AUTH_CONFIG_ROOT"] != installation.AuthenticationDirectory() {
|
||||||
|
return Installation{}, errors.New("authentication.configDirectory must match THT_AUTH_CONFIG_ROOT")
|
||||||
}
|
}
|
||||||
for _, override := range raw.Overrides {
|
for _, override := range raw.Overrides {
|
||||||
if err := requireRegularFile(override, "override"); err != nil {
|
if err := requireRegularFile(override, "override"); err != nil {
|
||||||
@@ -136,6 +158,9 @@ func Load(path string) (Installation, error) {
|
|||||||
return installation, nil
|
return installation, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AuthenticationDirectory returns the descriptor-owned, non-secret authentication root.
|
||||||
|
func (i Installation) AuthenticationDirectory() string { return i.Authentication.ConfigDirectory }
|
||||||
|
|
||||||
var safeGitBranch = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`)
|
var safeGitBranch = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`)
|
||||||
var scpSSHRemote = regexp.MustCompile(`^git@[^:/\s]+:[^\s]+$`)
|
var scpSSHRemote = regexp.MustCompile(`^git@[^:/\s]+:[^\s]+$`)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -67,6 +68,7 @@ func TestLoadRequiresAndReturnsTypedWorkspaceRepositoryForGitInstallations(t *te
|
|||||||
}
|
}
|
||||||
remote := "git@gitea.example.org:clinical/workspaces.git"
|
remote := "git@gitea.example.org:clinical/workspaces.git"
|
||||||
environment := strings.Join([]string{
|
environment := strings.Join([]string{
|
||||||
|
"THT_AUTH_CONFIG_ROOT=" + strconv.Quote(filepath.Join(filepath.Dir(envFile), "auth")),
|
||||||
"THT_WORKSPACE_GIT_REMOTE=" + remote,
|
"THT_WORKSPACE_GIT_REMOTE=" + remote,
|
||||||
"THT_WORKSPACE_GIT_BRANCH=main",
|
"THT_WORKSPACE_GIT_BRANCH=main",
|
||||||
"THT_WORKSPACE_GIT_SSH_KEY_FILE=" + privateKey,
|
"THT_WORKSPACE_GIT_SSH_KEY_FILE=" + privateKey,
|
||||||
@@ -77,6 +79,7 @@ func TestLoadRequiresAndReturnsTypedWorkspaceRepositoryForGitInstallations(t *te
|
|||||||
}
|
}
|
||||||
contents := "profile: local\nprojectDirectory: " + projectDirectory +
|
contents := "profile: local\nprojectDirectory: " + projectDirectory +
|
||||||
"\nenvFile: " + envFile +
|
"\nenvFile: " + envFile +
|
||||||
|
"\nauthentication:\n configDirectory: " + filepath.Join(filepath.Dir(envFile), "auth") +
|
||||||
"\nworkspaceRepository:\n remote: " + remote +
|
"\nworkspaceRepository:\n remote: " + remote +
|
||||||
"\n branch: main\n access: ssh\noverrides:\n - " + gitOverride + "\n"
|
"\n branch: main\n access: ssh\noverrides:\n - " + gitOverride + "\n"
|
||||||
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
|
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
|
||||||
@@ -102,6 +105,7 @@ func TestLoadRejectsGitOverrideWithoutTypedWorkspaceRepository(t *testing.T) {
|
|||||||
}
|
}
|
||||||
contents := "profile: local\nprojectDirectory: " + projectDirectory +
|
contents := "profile: local\nprojectDirectory: " + projectDirectory +
|
||||||
"\nenvFile: " + envFile + "\noverrides:\n - " + gitOverride + "\n"
|
"\nenvFile: " + envFile + "\noverrides:\n - " + gitOverride + "\n"
|
||||||
|
contents = strings.Replace(contents, "\noverrides:", "\nauthentication:\n configDirectory: "+filepath.Join(filepath.Dir(envFile), "auth")+"\noverrides:", 1)
|
||||||
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
|
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -194,6 +198,7 @@ func TestPreservationPathsReturnsCanonicalBindRootsBackupsAndSecretFiles(t *test
|
|||||||
}
|
}
|
||||||
wanted = append(wanted, secret)
|
wanted = append(wanted, secret)
|
||||||
lines = append(lines, "APP_TOKEN_FILE="+secret)
|
lines = append(lines, "APP_TOKEN_FILE="+secret)
|
||||||
|
lines = append(lines, "THT_AUTH_CONFIG_ROOT="+strconv.Quote(filepath.Join(root, "auth")))
|
||||||
if err := os.WriteFile(envFile, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
|
if err := os.WriteFile(envFile, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -239,6 +244,42 @@ func TestLoadRejectsRelativeInstallationPaths(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadRequiresCanonicalAuthenticationDirectoryMatchingEnvironment(t *testing.T) {
|
||||||
|
installationPath, _, envFile, _ := writeInstallation(t, "local")
|
||||||
|
authDirectory := filepath.Join(filepath.Dir(installationPath), "auth")
|
||||||
|
contents, err := os.ReadFile(installationPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(envFile, []byte("THT_AUTH_CONFIG_ROOT="+strconv.Quote(authDirectory)+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
installation, err := Load(installationPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := installation.AuthenticationDirectory(); got != authDirectory {
|
||||||
|
t.Fatalf("AuthenticationDirectory() = %q, want %q", got, authDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, invalid := range []string{"relative/auth", authDirectory + "/../auth"} {
|
||||||
|
bad := strings.Replace(string(contents), authDirectory, invalid, 1)
|
||||||
|
if err := os.WriteFile(installationPath, []byte(bad), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := Load(installationPath); err == nil {
|
||||||
|
t.Fatalf("Load accepted unsafe auth directory %q", invalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAuthenticationDirectoryEnvironment(t *testing.T) {
|
||||||
|
values, err := parseComposeDotenv([]byte("THT_AUTH_CONFIG_ROOT=\"/tmp/auth\"\n"))
|
||||||
|
if err != nil || values["THT_AUTH_CONFIG_ROOT"] != "/tmp/auth" {
|
||||||
|
t.Fatalf("values=%#v err=%v", values, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func writeInstallation(t *testing.T, profile string) (string, string, string, string) {
|
func writeInstallation(t *testing.T, profile string) (string, string, string, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -262,7 +303,8 @@ func writeInstallation(t *testing.T, profile string) (string, string, string, st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
envFile := filepath.Join(root, "environment file.env")
|
envFile := filepath.Join(root, "environment file.env")
|
||||||
if err := os.WriteFile(envFile, []byte("SAFE_VALUE=1\n"), 0o600); err != nil {
|
authDirectory := filepath.Join(root, "auth")
|
||||||
|
if err := os.WriteFile(envFile, []byte("SAFE_VALUE=1\nTHT_AUTH_CONFIG_ROOT="+strconv.Quote(authDirectory)+"\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
override := filepath.Join(root, "extra override.yaml")
|
override := filepath.Join(root, "extra override.yaml")
|
||||||
@@ -270,7 +312,7 @@ func writeInstallation(t *testing.T, profile string) (string, string, string, st
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
installationPath := filepath.Join(root, "thothii-installation.yaml")
|
installationPath := filepath.Join(root, "thothii-installation.yaml")
|
||||||
contents := "profile: " + profile + "\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\noverrides:\n - " + override + "\n"
|
contents := "profile: " + profile + "\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + authDirectory + "\noverrides:\n - " + override + "\n"
|
||||||
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
|
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,21 @@ import (
|
|||||||
|
|
||||||
var ErrUnsafeFile = errors.New("unsafe file")
|
var ErrUnsafeFile = errors.New("unsafe file")
|
||||||
|
|
||||||
|
// EnsurePrivateDirectory creates only the final canonical directory with the platform's
|
||||||
|
// owner-only protection, or validates an existing directory has that protection.
|
||||||
|
func EnsurePrivateDirectory(path string) error {
|
||||||
|
if err := ValidateCanonicalPath(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := requireCanonicalDirectory(filepath.Dir(path)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := createPrivateDirectory(path); err != nil && !errors.Is(err, os.ErrExist) {
|
||||||
|
return ErrUnsafeFile
|
||||||
|
}
|
||||||
|
return ValidatePrivateDirectory(path)
|
||||||
|
}
|
||||||
|
|
||||||
// ValidateCanonicalPath rejects relative or lexically non-canonical paths before they are opened.
|
// ValidateCanonicalPath rejects relative or lexically non-canonical paths before they are opened.
|
||||||
func ValidateCanonicalPath(path string) error {
|
func ValidateCanonicalPath(path string) error {
|
||||||
if !filepath.IsAbs(path) || filepath.Clean(path) != path || strings.Contains(path, string(filepath.Separator)+".."+string(filepath.Separator)) {
|
if !filepath.IsAbs(path) || filepath.Clean(path) != path || strings.Contains(path, string(filepath.Separator)+".."+string(filepath.Separator)) {
|
||||||
|
|||||||
@@ -3,10 +3,21 @@
|
|||||||
package safeio
|
package safeio
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func createPrivateDirectory(path string) error {
|
||||||
|
if err := os.Mkdir(path, 0o700); err != nil {
|
||||||
|
if errors.Is(err, os.ErrExist) {
|
||||||
|
return os.ErrExist
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ProtectPrivateDirectory(path)
|
||||||
|
}
|
||||||
|
|
||||||
// ProtectPrivateDirectory sets the private directory mode used for local authentication state.
|
// ProtectPrivateDirectory sets the private directory mode used for local authentication state.
|
||||||
func ProtectPrivateDirectory(path string) error {
|
func ProtectPrivateDirectory(path string) error {
|
||||||
if err := ValidateCanonicalPath(path); err != nil {
|
if err := ValidateCanonicalPath(path); err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
package safeio
|
package safeio
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -12,6 +13,32 @@ import (
|
|||||||
"golang.org/x/sys/windows"
|
"golang.org/x/sys/windows"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func createPrivateDirectory(path string) error {
|
||||||
|
parents, target, err := openCanonicalWindowsParent(path)
|
||||||
|
if err != nil {
|
||||||
|
return ErrUnsafeFile
|
||||||
|
}
|
||||||
|
defer parents.Close()
|
||||||
|
security, err := newOwnerOnlySecurityDescriptor()
|
||||||
|
if err != nil {
|
||||||
|
return ErrUnsafeFile
|
||||||
|
}
|
||||||
|
defer security.Close()
|
||||||
|
attributes := &windows.SecurityAttributes{
|
||||||
|
Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})),
|
||||||
|
SecurityDescriptor: security.descriptor,
|
||||||
|
}
|
||||||
|
err = windows.CreateDirectory(windows.StringToUTF16Ptr(filepath.Join(parents.directory, target)), attributes)
|
||||||
|
runtime.KeepAlive(security)
|
||||||
|
if errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
|
||||||
|
return os.ErrExist
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return ErrUnsafeFile
|
||||||
|
}
|
||||||
|
return ValidatePrivateDirectory(path)
|
||||||
|
}
|
||||||
|
|
||||||
// ProtectPrivateDirectory sets a protected DACL containing only the current owner.
|
// ProtectPrivateDirectory sets a protected DACL containing only the current owner.
|
||||||
func ProtectPrivateDirectory(path string) error {
|
func ProtectPrivateDirectory(path string) error {
|
||||||
parents, target, err := openCanonicalWindowsParent(path)
|
parents, target, err := openCanonicalWindowsParent(path)
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ type generatedDescriptor struct {
|
|||||||
Branch string `yaml:"branch"`
|
Branch string `yaml:"branch"`
|
||||||
Access string `yaml:"access"`
|
Access string `yaml:"access"`
|
||||||
} `yaml:"workspaceRepository"`
|
} `yaml:"workspaceRepository"`
|
||||||
|
Authentication struct {
|
||||||
|
ConfigDirectory string `yaml:"configDirectory"`
|
||||||
|
} `yaml:"authentication"`
|
||||||
Overrides []string `yaml:"overrides"`
|
Overrides []string `yaml:"overrides"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +77,9 @@ func EnsureFiles(request Request, input io.Reader, output io.Writer) (FilesResul
|
|||||||
}
|
}
|
||||||
descriptorPath := filepath.Join(directory, descriptorName)
|
descriptorPath := filepath.Join(directory, descriptorName)
|
||||||
environmentPath := filepath.Join(directory, environmentName)
|
environmentPath := filepath.Join(directory, environmentName)
|
||||||
|
if err := safeio.EnsurePrivateDirectory(filepath.Join(directory, "auth")); err != nil {
|
||||||
|
return FilesResult{}, errors.New("authentication directory is unavailable or unsafe")
|
||||||
|
}
|
||||||
result := FilesResult{DescriptorPath: descriptorPath, EnvironmentPath: environmentPath}
|
result := FilesResult{DescriptorPath: descriptorPath, EnvironmentPath: environmentPath}
|
||||||
|
|
||||||
descriptor, environment, err := render(root, descriptorPath, values)
|
descriptor, environment, err := render(root, descriptorPath, values)
|
||||||
@@ -298,6 +304,7 @@ func installationDirectory(root, id string) (string, error) {
|
|||||||
func render(root, descriptorPath string, value answers) ([]byte, []byte, error) {
|
func render(root, descriptorPath string, value answers) ([]byte, []byte, error) {
|
||||||
descriptor := generatedDescriptor{Profile: value.profile, ProjectDirectory: root, EnvFile: filepath.Join(filepath.Dir(descriptorPath), environmentName)}
|
descriptor := generatedDescriptor{Profile: value.profile, ProjectDirectory: root, EnvFile: filepath.Join(filepath.Dir(descriptorPath), environmentName)}
|
||||||
descriptor.Workspace.Remote, descriptor.Workspace.Branch, descriptor.Workspace.Access = value.workspaceRemote, value.workspaceBranch, value.workspaceAccess
|
descriptor.Workspace.Remote, descriptor.Workspace.Branch, descriptor.Workspace.Access = value.workspaceRemote, value.workspaceBranch, value.workspaceAccess
|
||||||
|
descriptor.Authentication.ConfigDirectory = filepath.Join(filepath.Dir(descriptorPath), "auth")
|
||||||
descriptor.Overrides = []string{filepath.Join(root, "deploy", "compose.git-"+value.workspaceAccess+".yaml")}
|
descriptor.Overrides = []string{filepath.Join(root, "deploy", "compose.git-"+value.workspaceAccess+".yaml")}
|
||||||
descriptorBytes, err := yaml.Marshal(descriptor)
|
descriptorBytes, err := yaml.Marshal(descriptor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -308,6 +315,7 @@ func render(root, descriptorPath string, value answers) ([]byte, []byte, error)
|
|||||||
"THT_WORKSPACE_GIT_REMOTE=" + dotenvValue(value.workspaceRemote),
|
"THT_WORKSPACE_GIT_REMOTE=" + dotenvValue(value.workspaceRemote),
|
||||||
"THT_WORKSPACE_GIT_BRANCH=" + dotenvValue(value.workspaceBranch),
|
"THT_WORKSPACE_GIT_BRANCH=" + dotenvValue(value.workspaceBranch),
|
||||||
"THT_WORKSPACE_INSTALLATION_ID=" + dotenvValue(value.installationID),
|
"THT_WORKSPACE_INSTALLATION_ID=" + dotenvValue(value.installationID),
|
||||||
|
"THT_AUTH_CONFIG_ROOT=" + dotenvValue(descriptor.Authentication.ConfigDirectory),
|
||||||
"THT_SECRETS_FILE=" + dotenvValue(value.secretsFile),
|
"THT_SECRETS_FILE=" + dotenvValue(value.secretsFile),
|
||||||
"PI_AUTH_FILE=" + dotenvValue(value.piAuthFile),
|
"PI_AUTH_FILE=" + dotenvValue(value.piAuthFile),
|
||||||
"THOTH_HTTP_PORT=8080", "THOTH_CORE_HTTP_PORT=8787", "MAX_PI_PROCESSES=4",
|
"THOTH_HTTP_PORT=8080", "THOTH_CORE_HTTP_PORT=8787", "MAX_PI_PROCESSES=4",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||||
|
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
||||||
"github.com/aritmolab/thothii/tools/tht/internal/testsupport"
|
"github.com/aritmolab/thothii/tools/tht/internal/testsupport"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,6 +49,13 @@ func TestEnsureFilesCreatesDiscoverableConfigurationInProjectWithSpaces(t *testi
|
|||||||
t.Errorf("generated file %s has permissions %o, want owner-only", path, info.Mode().Perm())
|
t.Errorf("generated file %s has permissions %o, want owner-only", path, info.Mode().Perm())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
installation, err := config.Load(result.DescriptorPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := safeio.ValidatePrivateDirectory(installation.AuthenticationDirectory()); err != nil {
|
||||||
|
t.Fatalf("authentication directory is not private: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
descriptor, err := os.ReadFile(result.DescriptorPath)
|
descriptor, err := os.ReadFile(result.DescriptorPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user