Files
ThothII/tools/tht/internal/authconfig/commands.go
T

629 lines
19 KiB
Go

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()
}