quorate/services/stalwart/cmd/controller/app_password.go

427 lines
13 KiB
Go
Raw Normal View History

package main
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
)
var appPasswordPattern = regexp.MustCompile(`app_[A-Za-z0-9]+`)
type vaultClient struct {
address string
token string
client *http.Client
}
func newVaultHTTPClient(caFile string) (*http.Client, error) {
certificate, err := os.ReadFile(caFile)
if err != nil {
return nil, err
}
roots, err := x509.SystemCertPool()
if err != nil {
return nil, err
}
if !roots.AppendCertsFromPEM(certificate) {
return nil, errors.New("vault CA file contains no certificates")
}
transport := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: roots}}
return &http.Client{Timeout: 15 * time.Second, Transport: transport}, nil
}
func vaultLogin(ctx context.Context, value options) (vaultClient, error) {
client, err := newVaultHTTPClient(value.vaultCACertFile)
if err != nil {
return vaultClient{}, err
}
jwt, err := readSecret(value.vaultTokenFile)
if err != nil {
return vaultClient{}, err
}
payload, err := json.Marshal(map[string]string{"role": value.vaultRole, "jwt": jwt})
if err != nil {
return vaultClient{}, err
}
target := strings.TrimRight(value.vaultAddress, "/") + "/v1/auth/kubernetes/login"
request, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewReader(payload))
if err != nil {
return vaultClient{}, err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.Do(request)
if err != nil {
return vaultClient{}, err
}
defer func() { _ = response.Body.Close() }()
if response.StatusCode != http.StatusOK {
return vaultClient{}, responseStatusError("vault Kubernetes login", response, jwt)
}
var result struct {
Auth struct {
ClientToken string `json:"client_token"`
} `json:"auth"`
}
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&result); err != nil {
return vaultClient{}, err
}
if result.Auth.ClientToken == "" {
return vaultClient{}, errors.New("vault login response has no client token")
}
return vaultClient{address: strings.TrimRight(value.vaultAddress, "/"), token: result.Auth.ClientToken, client: client}, nil
}
func (client vaultClient) request(ctx context.Context, method, path string, payload []byte) (*http.Response, error) {
request, err := http.NewRequestWithContext(ctx, method, client.address+"/v1/"+path, bytes.NewReader(payload))
if err != nil {
return nil, err
}
request.Header.Set("X-Vault-Token", client.token)
if len(payload) != 0 {
request.Header.Set("Content-Type", "application/json")
}
return client.client.Do(request)
}
type storedAppPassword struct {
ID string
Password string
}
func (client vaultClient) appPassword(ctx context.Context, path, key string) (storedAppPassword, bool, error) {
response, err := client.request(ctx, http.MethodGet, path, nil)
if err != nil {
return storedAppPassword{}, false, fmt.Errorf("read app password from vault: %w", err)
}
defer func() { _ = response.Body.Close() }()
if response.StatusCode == http.StatusNotFound {
return storedAppPassword{}, false, nil
}
if response.StatusCode != http.StatusOK {
return storedAppPassword{}, false, fmt.Errorf("read app password from vault: HTTP %d", response.StatusCode)
}
var result struct {
Data struct {
Data map[string]string `json:"data"`
} `json:"data"`
}
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&result); err != nil {
return storedAppPassword{}, false, fmt.Errorf("decode app password from vault: %w", err)
}
credential := storedAppPassword{
ID: result.Data.Data["stalwart_app_password_id"],
Password: result.Data.Data[key],
}
return credential, credential.Password != "", nil
}
func (client vaultClient) writeAppPassword(ctx context.Context, path, key string, credential storedAppPassword) error {
payload, err := json.Marshal(map[string]any{"data": map[string]string{
key: credential.Password,
"stalwart_app_password_id": credential.ID,
}})
if err != nil {
return fmt.Errorf("encode app password for vault: %w", err)
}
response, err := client.request(ctx, http.MethodPost, path, payload)
if err != nil {
return fmt.Errorf("write app password to vault: %w", err)
}
defer func() { _ = response.Body.Close() }()
if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusNoContent {
return fmt.Errorf("write app password to vault: HTTP %d", response.StatusCode)
}
return nil
}
type appPassword struct {
ID string `json:"id"`
Description string `json:"description"`
}
func appPasswords(output []byte) ([]appPassword, error) {
decoder := json.NewDecoder(bytes.NewReader(output))
var items []appPassword
for {
var item appPassword
err := decoder.Decode(&item)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
if item.ID == "" {
return nil, errors.New("AppPassword query returned an empty id")
}
items = append(items, item)
}
return items, nil
}
func createdAppPassword(output []byte) (string, error) {
matches := appPasswordPattern.FindAll(output, -1)
if len(matches) != 1 {
return "", errors.New("AppPassword creation did not return one credential")
}
return string(matches[0]), nil
}
func appPasswordToken(ctx context.Context, value options, clientIDFile, clientSecretFile string) (string, error) {
return oidcTokenFromFiles(ctx, value.oidcIssuerFile, value.oidcAudienceFile, clientIDFile, clientSecretFile)
}
func appPasswordWorks(ctx context.Context, baseURL, username, password string) (bool, error) {
target, err := url.Parse(baseURL)
if err != nil || target.Scheme == "" || target.Host == "" {
return false, errors.New("stalwart URL is invalid")
}
target.Path = "/jmap/session"
request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
if err != nil {
return false, fmt.Errorf("build app-password verification request: %w", err)
}
request.SetBasicAuth(username, password)
client := &http.Client{Timeout: 15 * time.Second}
response, err := client.Do(request)
if err != nil {
return false, fmt.Errorf("verify app password: %w", err)
}
defer func() { _ = response.Body.Close() }()
switch response.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusUnauthorized, http.StatusForbidden:
return false, nil
default:
return false, fmt.Errorf("verify app password: HTTP %d", response.StatusCode)
}
}
type appPasswordIdentity struct {
ClientIDFile string `json:"clientIdFile"`
ClientSecretFile string `json:"clientSecretFile"`
Username string `json:"username"`
Description string `json:"description"`
VaultPath string `json:"vaultPath"`
VaultKey string `json:"vaultKey"`
Permissions json.RawMessage `json:"permissions"`
}
func loadAppPasswordIdentities(path string) ([]appPasswordIdentity, error) {
content, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var identities []appPasswordIdentity
if err := json.Unmarshal(content, &identities); err != nil {
return nil, err
}
for index, identity := range identities {
if identity.ClientIDFile == "" || identity.ClientSecretFile == "" || identity.Username == "" ||
identity.Description == "" || identity.VaultPath == "" || identity.VaultKey == "" || len(identity.Permissions) == 0 {
return nil, fmt.Errorf("app-password identity %d is incomplete", index)
}
var permissions map[string]any
if err := json.Unmarshal(identity.Permissions, &permissions); err != nil || len(permissions) == 0 {
return nil, fmt.Errorf("app-password identity %d has invalid permissions", index)
}
}
return identities, nil
}
type appPasswordOperations struct {
query func() ([]appPassword, error)
create func([]appPassword) (storedAppPassword, error)
delete func([]string) error
verify func(string) (bool, error)
read func() (storedAppPassword, bool, error)
write func(storedAppPassword) error
}
func queryAppPasswords(ctx context.Context, value options, environment []string) ([]appPassword, error) {
output, err := runCLI(ctx, value.cli, environment, true, "query", "AppPassword", "--fields", "id,description", "--json", "--no-color")
if err != nil {
return nil, err
}
items, err := appPasswords(output)
if err != nil {
return nil, fmt.Errorf("decode app-password inventory: %w", err)
}
return items, nil
}
func appPasswordIDs(items []appPassword, except string) []string {
ids := make([]string, 0, len(items))
for _, item := range items {
if item.ID != except {
ids = append(ids, item.ID)
}
}
return ids
}
func deleteAppPasswords(ctx context.Context, value options, environment []string, ids []string) error {
if len(ids) == 0 {
return nil
}
_, err := runCLI(ctx, value.cli, environment, false, "delete", "AppPassword", "--ids", strings.Join(ids, ","), "--no-color")
return err
}
func createAppPassword(
ctx context.Context,
value options,
environment []string,
identity appPasswordIdentity,
existing []appPassword,
) (storedAppPassword, error) {
output, err := runCLI(
ctx,
value.cli,
environment,
true,
"create",
"AppPassword",
"--field",
"description="+identity.Description,
"--field",
"permissions="+string(identity.Permissions),
"--field",
"allowedIps={}",
"--no-color",
)
if err != nil {
return storedAppPassword{}, err
}
password, err := createdAppPassword(output)
if err != nil {
return storedAppPassword{}, fmt.Errorf("decode created app password: %w", err)
}
existingIDs := make(map[string]struct{}, len(existing))
for _, item := range existing {
existingIDs[item.ID] = struct{}{}
}
current, err := queryAppPasswords(ctx, value, environment)
if err != nil {
return storedAppPassword{}, err
}
var created []appPassword
for _, item := range current {
if _, existed := existingIDs[item.ID]; !existed && item.Description == identity.Description {
created = append(created, item)
}
}
if len(created) != 1 {
return storedAppPassword{}, errors.New("created app password is not uniquely identifiable")
}
return storedAppPassword{ID: created[0].ID, Password: password}, nil
}
func convergeAppPassword(description string, operations appPasswordOperations) error {
items, err := operations.query()
if err != nil {
return err
}
credential, present, err := operations.read()
if err != nil {
return err
}
if present {
valid, verifyErr := operations.verify(credential.Password)
if verifyErr != nil {
return verifyErr
}
if valid {
if credential.ID != "" {
for _, item := range items {
if item.ID == credential.ID && item.Description == description {
return operations.delete(appPasswordIDs(items, credential.ID))
}
}
}
if len(items) == 1 && items[0].Description == description {
credential.ID = items[0].ID
return operations.write(credential)
}
}
}
created, err := operations.create(items)
if err != nil {
return err
}
valid, verifyErr := operations.verify(created.Password)
if verifyErr != nil {
cleanupErr := operations.delete([]string{created.ID})
return errors.Join(verifyErr, cleanupErr)
}
if !valid {
cleanupErr := operations.delete([]string{created.ID})
return errors.Join(errors.New("new app password failed authentication"), cleanupErr)
}
if err := operations.write(created); err != nil {
cleanupErr := operations.delete([]string{created.ID})
return errors.Join(err, cleanupErr)
}
return operations.delete(appPasswordIDs(items, created.ID))
}
func reconcileAppPassword(ctx context.Context, value options, vault vaultClient, identity appPasswordIdentity) error {
token, err := appPasswordToken(ctx, value, identity.ClientIDFile, identity.ClientSecretFile)
if err != nil {
return fmt.Errorf("issue app-password token: %w", err)
}
environment := []string{"STALWART_TOKEN=" + token, "STALWART_URL=" + value.stalwartURL}
operations := appPasswordOperations{
query: func() ([]appPassword, error) {
return queryAppPasswords(ctx, value, environment)
},
create: func(existing []appPassword) (storedAppPassword, error) {
return createAppPassword(ctx, value, environment, identity, existing)
},
delete: func(ids []string) error {
return deleteAppPasswords(ctx, value, environment, ids)
},
verify: func(password string) (bool, error) {
return appPasswordWorks(ctx, value.stalwartURL, identity.Username, password)
},
read: func() (storedAppPassword, bool, error) {
return vault.appPassword(ctx, identity.VaultPath, identity.VaultKey)
},
write: func(credential storedAppPassword) error {
return vault.writeAppPassword(ctx, identity.VaultPath, identity.VaultKey, credential)
},
}
return convergeAppPassword(identity.Description, operations)
}
func reconcileAppPasswords(ctx context.Context, value options) error {
if value.appPasswords == "" {
return nil
}
identities, err := loadAppPasswordIdentities(value.appPasswords)
if err != nil {
return fmt.Errorf("load app-password identities: %w", err)
}
vault, err := vaultLogin(ctx, value)
if err != nil {
return fmt.Errorf("log in to vault: %w", err)
}
for _, identity := range identities {
if err := reconcileAppPassword(ctx, value, vault, identity); err != nil {
return fmt.Errorf("reconcile app-password identity %q: %w", identity.Description, err)
}
}
return nil
}