2fa ssh

From UVOO Tech Wiki
Revision as of 12:40, 16 September 2026 by Busk (talk | contribs)
Jump to navigation Jump to search

Using 2FA

Require OTP via Google Authenticator App

publickey,keyboard-interactive (and - requires both)

publickey keyboard-interactive (or - requires one or the other)

Ubuntu 26.04

Prep user

adduser myuser --disabled-password
# usermod -aG sudo myuser
visudo  # myuser   ALL=(ALL:ALL) NOPASSWD: ALL
sudo su - myuser
mkdir .ssh
nano .ssh/authorized_keys  # paste public ssh key

Update SSHD to require both SSH ID and then OTP

#!/usr/bin/env bash
set -e

sudo apt update
sudo apt install -y libpam-google-authenticator

# Run as the login user, NOT root/sudo — generates ~/.google_authenticator
google-authenticator

# Insert BEFORE @include common-auth if you want pubkey+OTP only, no password.
# As a quick check, comment out the password-based auth line so PAM only asks for the OTP:
sudo sed -i 's/^@include common-auth/#&/' /etc/pam.d/sshd
sudo sed -i '1 i\auth required pam_google_authenticator.so' /etc/pam.d/sshd

sudo tee /etc/ssh/sshd_config.d/60-mfa.conf > /dev/null <<'EOF'
KbdInteractiveAuthentication yes
PasswordAuthentication no
AuthenticationMethods publickey,keyboard-interactive
EOF

sudo systemctl restart ssh

KeyCloak realm

setup-authd-keycloak.sh
#!/usr/bin/env bash
# setup-authd-keycloak.sh
#
# Idempotent Ubuntu 26.04 Server bootstrap for Canonical authd + generic OIDC
# broker (authd-oidc), intended for Keycloak device authorization.
#
# Default SSH policy for OIDC users:
#   - SSH password authentication: disabled
#   - SSH public-key authentication: disabled for the OIDC suffix
#   - PAM keyboard-interactive/authd: required
#
# This ensures OIDC users cannot bypass authd/Keycloak with an SSH key.
#
# IMPORTANT:
# Keycloak OTP/WebAuthn policy is configured in Keycloak, not by this script.
# Configure the Keycloak client/authentication flow to require the desired MFA.

set -Eeuo pipefail
IFS=$'\n\t'

PROGRAM="${0##*/}"

ISSUER=""
CLIENT_ID=""
SSH_SUFFIX=""
ALLOWED_USERS=""
CLIENT_SECRET=""
EXTRA_SCOPES="offline_access"
FORCE_PROVIDER_AUTHENTICATION="true"
HOME_BASE_DIR="/home"
EXTRA_GROUPS=""
SSH_POLICY="authd-only"
BROKER_CHANNEL="0.x/stable"
SKIP_OIDC_CHECK="false"

BROKER_SNAP="authd-oidc"
BROKER_CONF=""
BROKER_DATA_DIR=""
BROKER_DISCOVERY_SRC="/snap/authd-oidc/current/conf/authd/oidc.conf"
BROKER_DISCOVERY_DST="/etc/authd/brokers.d/oidc.conf"
SSHD_DROPIN="/etc/ssh/sshd_config.d/00-authd-oidc.conf"

usage() {
    cat <<EOF
Usage:
  sudo ./${PROGRAM} \\
    --issuer URL \\
    --client-id ID \\
    --ssh-suffix @DOMAIN \\
    --allowed-users USERS

Required:
  --issuer URL
      OIDC issuer / Keycloak realm URL.
      Example: https://auth.example.com/realms/linux

  --client-id ID
      Keycloak OIDC client ID.
      Example: ubuntu-authd

  --ssh-suffix @DOMAIN
      Username suffix allowed to perform first SSH authentication.
      Exactly one suffix is supported by this script.
      Example: @example.com

  --allowed-users USERS
      authd broker allowed_users value.
      Examples:
        ALL
        OWNER
        user1@example.com,user2@example.com

Optional:
  --client-secret SECRET
      OIDC client secret, only when your Keycloak client requires one.
      Prefer a public client for device authorization when appropriate.
      WARNING: a secret supplied on the command line may be visible in shell
      history or process listings.

  --extra-scopes SCOPES
      Comma-separated additional OIDC scopes.
      Default: offline_access

  --force-provider-authentication true|false
      Require provider/token refresh validation even for authd local-password
      authentication.
      Default: true

  --home-base-dir DIR
      Base directory for authd-created home directories.
      Default: /home

  --extra-groups GROUPS
      Optional comma-separated local groups to assign to authd users.
      Example: sudo
      Default: unset

  --ssh-policy POLICY
      authd-only       Require authd/Keycloak for OIDC users. This is the
                       recommended default for JIT-provisioned SSH users.
      publickey+authd  Require BOTH SSH public key AND authd/Keycloak.
                       The account/key must already be resolvable before SSH
                       reaches authd, so this is normally unsuitable for the
                       very first JIT login.
      Default: authd-only

  --broker-channel CHANNEL
      Snap channel used when authd-oidc is first installed.
      Existing installations are not automatically switched.
      Default: 0.x/stable

  --skip-oidc-check
      Do not validate the issuer discovery document before changing config.

  -h, --help
      Show this help.

Example:
  sudo ./${PROGRAM} \\
    --issuer https://auth.uvoo.io/realms/test \\
    --client-id test \\
    --ssh-suffix @uvoo.io \\
    --allowed-users ALL

More restrictive production example:
  sudo ./${PROGRAM} \\
    --issuer https://auth.uvoo.io/realms/test \\
    --client-id test \\
    --ssh-suffix @uvoo.io \\
    --allowed-users 'test1@uvoo.io,admin1@uvoo.io'
EOF
}

die() {
    printf 'ERROR: %s\n' "$*" >&2
    exit 1
}

log() {
    printf '==> %s\n' "$*"
}

on_error() {
    local rc=$?
    printf 'ERROR: command failed at line %s (exit %s)\n' "${BASH_LINENO[0]}" "$rc" >&2
    exit "$rc"
}
trap on_error ERR

require_value() {
    local opt="$1"
    local value="${2-}"
    [[ -n "$value" ]] || die "${opt} requires a value"
}

while (($#)); do
    case "$1" in
        --issuer)
            require_value "$1" "${2-}"
            ISSUER="$2"
            shift 2
            ;;
        --client-id)
            require_value "$1" "${2-}"
            CLIENT_ID="$2"
            shift 2
            ;;
        --ssh-suffix)
            require_value "$1" "${2-}"
            SSH_SUFFIX="$2"
            shift 2
            ;;
        --allowed-users)
            require_value "$1" "${2-}"
            ALLOWED_USERS="$2"
            shift 2
            ;;
        --client-secret)
            require_value "$1" "${2-}"
            CLIENT_SECRET="$2"
            shift 2
            ;;
        --extra-scopes)
            require_value "$1" "${2-}"
            EXTRA_SCOPES="$2"
            shift 2
            ;;
        --force-provider-authentication)
            require_value "$1" "${2-}"
            FORCE_PROVIDER_AUTHENTICATION="$2"
            shift 2
            ;;
        --home-base-dir)
            require_value "$1" "${2-}"
            HOME_BASE_DIR="$2"
            shift 2
            ;;
        --extra-groups)
            require_value "$1" "${2-}"
            EXTRA_GROUPS="$2"
            shift 2
            ;;
        --ssh-policy)
            require_value "$1" "${2-}"
            SSH_POLICY="$2"
            shift 2
            ;;
        --broker-channel)
            require_value "$1" "${2-}"
            BROKER_CHANNEL="$2"
            shift 2
            ;;
        --skip-oidc-check)
            SKIP_OIDC_CHECK="true"
            shift
            ;;
        -h|--help)
            usage
            exit 0
            ;;
        *)
            die "unknown argument: $1 (use --help)"
            ;;
    esac
done

[[ -n "$ISSUER" ]]       || die "--issuer is required"
[[ -n "$CLIENT_ID" ]]    || die "--client-id is required"
[[ -n "$SSH_SUFFIX" ]]   || die "--ssh-suffix is required"
[[ -n "$ALLOWED_USERS" ]] || die "--allowed-users is required"

[[ "$ISSUER" == https://* ]] || die "--issuer must use https://"
ISSUER="${ISSUER%/}"

[[ "$SSH_SUFFIX" == @* ]] || die "--ssh-suffix must begin with '@'"
[[ "$SSH_SUFFIX" != *","* ]] || die "--ssh-suffix accepts exactly one suffix"
[[ "$SSH_SUFFIX" != *[[:space:]]* ]] || die "--ssh-suffix must not contain whitespace"

case "$FORCE_PROVIDER_AUTHENTICATION" in
    true|false) ;;
    *) die "--force-provider-authentication must be true or false" ;;
esac

case "$SSH_POLICY" in
    authd-only|publickey+authd) ;;
    *) die "--ssh-policy must be authd-only or publickey+authd" ;;
esac

for value_name in ISSUER CLIENT_ID SSH_SUFFIX ALLOWED_USERS CLIENT_SECRET EXTRA_SCOPES HOME_BASE_DIR EXTRA_GROUPS; do
    value="${!value_name}"
    [[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] \
        || die "${value_name} must not contain newlines"
done

if [[ $EUID -ne 0 ]]; then
    die "run this script as root, e.g. sudo ./${PROGRAM} ..."
fi

[[ -r /etc/os-release ]] || die "cannot read /etc/os-release"
# shellcheck disable=SC1091
source /etc/os-release
[[ "${ID:-}" == "ubuntu" ]] || die "this script supports Ubuntu only"
[[ "${VERSION_ID:-}" == "26.04" ]] \
    || die "this script is intentionally limited to Ubuntu 26.04; found ${VERSION_ID:-unknown}"

export DEBIAN_FRONTEND=noninteractive

install_packages() {
    local required=(
    authd
    openssh-server
    snapd
    curl
    ca-certificates
    python3
    cracklib-runtime
    wamerican
)
    local missing=()
    local pkg

    for pkg in "${required[@]}"; do
        if ! dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -qx 'install ok installed'; then
            missing+=("$pkg")
        fi
    done

    if ((${#missing[@]})); then
        log "Installing required packages: ${missing[*]}"
        apt-get update
        apt-get install -y --no-install-recommends "${missing[@]}"
    else
        log "Required Debian packages already installed"
    fi

    systemctl enable --now snapd.socket >/dev/null
    # authd may be socket/D-Bus activated depending on package version; start it
    # without assuming the unit has an [Install] section.
    systemctl start authd
    systemctl enable --now ssh >/dev/null
}

install_broker() {
    if snap list "$BROKER_SNAP" >/dev/null 2>&1; then
        log "$BROKER_SNAP is already installed; leaving its current snap channel/revision unchanged"
        snap list "$BROKER_SNAP"
    else
        log "Installing $BROKER_SNAP from channel $BROKER_CHANNEL"
        snap install "$BROKER_SNAP" --channel="$BROKER_CHANNEL"
    fi

    # Resolve the active snap revision explicitly. The service wrapper uses the
    # revision-specific SNAP_DATA path (for example /var/snap/authd-oidc/89).
    local revision
    revision="$(snap list "$BROKER_SNAP" | awk 'NR==2 {print $3}')"
    [[ "$revision" =~ ^[0-9]+$ ]] || die "could not determine active $BROKER_SNAP revision"

    BROKER_DATA_DIR="/var/snap/${BROKER_SNAP}/${revision}"
    BROKER_CONF="${BROKER_DATA_DIR}/broker.conf"

    [[ -f "$BROKER_CONF" ]] \
        || die "broker configuration was not created at $BROKER_CONF"
    [[ -f "$BROKER_DISCOVERY_SRC" ]] \
        || die "broker discovery file not found at $BROKER_DISCOVERY_SRC"

    log "Active broker revision: $revision"
    log "Active broker config: $BROKER_CONF"
}

validate_oidc() {
    [[ "$SKIP_OIDC_CHECK" == "true" ]] && {
        log "Skipping OIDC discovery validation"
        return
    }

    local discovery="${ISSUER}/.well-known/openid-configuration"
    local tmp
    tmp="$(mktemp)"
    trap 'rm -f "$tmp"' RETURN

    log "Validating OIDC discovery document: $discovery"
    curl --fail --silent --show-error --location \
        --connect-timeout 5 --max-time 15 \
        "$discovery" -o "$tmp"

    python3 - "$tmp" "$ISSUER" <<'PY'
import json
import sys

path, expected_issuer = sys.argv[1], sys.argv[2]
with open(path, encoding="utf-8") as f:
    d = json.load(f)

actual_issuer = str(d.get("issuer", "")).rstrip("/")
if actual_issuer != expected_issuer.rstrip("/"):
    raise SystemExit(
        f"OIDC discovery issuer mismatch: expected {expected_issuer!r}, got {actual_issuer!r}"
    )

device_ep = d.get("device_authorization_endpoint")
if not device_ep:
    raise SystemExit(
        "OIDC provider does not advertise device_authorization_endpoint; "
        "authd Device Authentication will not work."
    )

print(f"OIDC issuer OK: {actual_issuer}")
print(f"Device authorization endpoint: {device_ep}")
PY

    rm -f "$tmp"
    trap - RETURN
}

install_if_changed() {
    local source="$1"
    local destination="$2"
    local mode="$3"

    if [[ -f "$destination" ]] && cmp -s "$source" "$destination"; then
        return 1
    fi

    install -D -m "$mode" "$source" "$destination"
    return 0
}

validate_broker_files_no_placeholders() {
    local bad=0
    local f

    # authd-oidc 0.4.1 rejects any parsed config value containing both '<' and '>'.
    # Scan the active main file and all drop-ins before attempting a restart.
    if grep -nE '<[^>]+>' "$BROKER_CONF" >/dev/null 2>&1; then
        printf 'ERROR: unresolved template placeholder(s) in %s:\n' "$BROKER_CONF" >&2
        grep -nE '<[^>]+>' "$BROKER_CONF" >&2 || true
        bad=1
    fi

    if [[ -d "${BROKER_CONF}.d" ]]; then
        while IFS= read -r -d '' f; do
            if grep -nE '<[^>]+>' "$f" >/dev/null 2>&1; then
                printf 'ERROR: unresolved template placeholder(s) in drop-in %s:\n' "$f" >&2
                grep -nE '<[^>]+>' "$f" >&2 || true
                bad=1
            fi
        done < <(find "${BROKER_CONF}.d" -maxdepth 1 -type f -print0)
    fi

    (( bad == 0 )) || die "authd-oidc configuration contains unresolved <...> placeholders"
}

configure_broker_discovery() {
    mkdir -p /etc/authd/brokers.d

    if install_if_changed "$BROKER_DISCOVERY_SRC" "$BROKER_DISCOVERY_DST" 0644; then
        log "Updated authd broker discovery: $BROKER_DISCOVERY_DST"
        AUTHD_CHANGED=true
    else
        log "authd broker discovery already correct"
    fi
}

configure_broker() {
    local tmp
    tmp="$(mktemp)"
    trap 'rm -f "$tmp"' RETURN

    {
        printf '[oidc]\n'
        printf 'issuer = %s\n' "$ISSUER"
        printf 'client_id = %s\n' "$CLIENT_ID"

        if [[ -n "$CLIENT_SECRET" ]]; then
            printf 'client_secret = %s\n' "$CLIENT_SECRET"
        fi

        if [[ -n "$EXTRA_SCOPES" ]]; then
            printf 'extra_scopes = %s\n' "$EXTRA_SCOPES"
        fi

        printf 'force_provider_authentication = %s\n' "$FORCE_PROVIDER_AUTHENTICATION"
        printf '\n'
        printf '[users]\n'
        printf 'ssh_allowed_suffixes_first_auth = %s\n' "$SSH_SUFFIX"
        printf 'allowed_users = %s\n' "$ALLOWED_USERS"
        printf 'home_base_dir = %s\n' "$HOME_BASE_DIR"

        if [[ -n "$EXTRA_GROUPS" ]]; then
            printf 'extra_groups = %s\n' "$EXTRA_GROUPS"
        fi
    } >"$tmp"

    # Preserve the snap-provided/generated file the first time we take control.
    if [[ -f "$BROKER_CONF" && ! -e "${BROKER_CONF}.pre-${PROGRAM}" ]]; then
        cp -a "$BROKER_CONF" "${BROKER_CONF}.pre-${PROGRAM}"
        log "Saved original broker config as ${BROKER_CONF}.pre-${PROGRAM}"
    fi

    if install_if_changed "$tmp" "$BROKER_CONF" 0600; then
        log "Updated broker configuration: $BROKER_CONF"
        BROKER_CHANGED=true
    else
        log "Broker configuration already correct"
    fi

    rm -f "$tmp"
    trap - RETURN

    validate_broker_files_no_placeholders
}

configure_sshd() {
    local tmp
    tmp="$(mktemp)"
    trap 'rm -f "$tmp"' RETURN

    cat >"$tmp" <<EOF
# Managed by ${PROGRAM}
#
# Local/break-glass Unix accounts may still use SSH public keys.
# OIDC users matching ${SSH_SUFFIX} are forced through authd/PAM.

UsePAM yes
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes

Match User *${SSH_SUFFIX}
    PasswordAuthentication no
    KbdInteractiveAuthentication yes
EOF

    case "$SSH_POLICY" in
        authd-only)
            cat >>"$tmp" <<'EOF'
    PubkeyAuthentication no
    AuthenticationMethods keyboard-interactive:pam
EOF
            ;;
        publickey+authd)
            cat >>"$tmp" <<'EOF'
    PubkeyAuthentication yes
    AuthenticationMethods publickey,keyboard-interactive:pam
EOF
            ;;
    esac

    if install_if_changed "$tmp" "$SSHD_DROPIN" 0644; then
        log "Updated SSH configuration: $SSHD_DROPIN"
        SSH_CHANGED=true
    else
        log "SSH configuration already correct"
    fi

    rm -f "$tmp"
    trap - RETURN

    log "Validating sshd configuration"
    sshd -t
}

restart_if_needed() {
    if [[ "$AUTHD_CHANGED" == "true" ]]; then
        log "Restarting authd"
        systemctl restart authd
    fi

    if [[ "$BROKER_CHANGED" == "true" || "$AUTHD_CHANGED" == "true" ]]; then
        log "Restarting authd-oidc broker"
        if ! snap restart authd-oidc >/dev/null; then
            printf 'ERROR: authd-oidc failed to restart. Recent broker log:\n' >&2
            journalctl -u snap.authd-oidc.authd-oidc.service -b -n 60 --no-pager >&2 || true

            if [[ -f "${BROKER_CONF}.pre-${PROGRAM}" ]]; then
                printf 'ERROR: restoring previous broker configuration and retrying service start\n' >&2
                cp -a "${BROKER_CONF}.pre-${PROGRAM}" "$BROKER_CONF"
                systemctl reset-failed snap.authd-oidc.authd-oidc.service || true
                snap restart authd-oidc >/dev/null || true
            fi
            die "authd-oidc broker restart failed; previous config was restored when available"
        fi
    fi

    if [[ "$SSH_CHANGED" == "true" ]]; then
        log "Restarting SSH"
        systemctl restart ssh
    fi
}

show_effective_config() {
    local probe_user="authd-probe${SSH_SUFFIX}"

    log "Service status"
    systemctl is-active --quiet authd && echo "authd: active"
    snap services authd-oidc
    systemctl is-active --quiet ssh && echo "ssh: active"

    log "Effective SSH authentication settings for ${probe_user}"
    sshd -T -C "user=${probe_user},host=localhost,addr=127.0.0.1" 2>/dev/null \
        | grep -E '^(usepam|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|authenticationmethods) ' \
        || true

    log "Installed versions"
    dpkg-query -W -f='authd: ${Version}\n' authd 2>/dev/null || true
    snap list authd-oidc 2>/dev/null || true

    cat <<EOF

Setup complete.

OIDC issuer:      ${ISSUER}
OIDC client ID:   ${CLIENT_ID}
SSH suffix:       ${SSH_SUFFIX}
Allowed users:    ${ALLOWED_USERS}
SSH policy:       ${SSH_POLICY}

Test from a SECOND terminal while keeping your current administrative
session open:

  ssh 'user${SSH_SUFFIX}'@<server>

For Keycloak MFA:
  Configure the Keycloak authentication flow/client so Device Authorization
  requires OTP/WebAuthn. This script configures the Ubuntu side only.

EOF

    if [[ "$SSH_POLICY" == "publickey+authd" ]]; then
        cat <<'EOF'
NOTE:
  publickey+authd requires the SSH public key to succeed before authd runs.
  For a brand-new JIT user, pre-provision the account/key or use centralized
  SSH certificates / AuthorizedKeysCommand. Otherwise first login can fail.

EOF
    fi
}

AUTHD_CHANGED=false
BROKER_CHANGED=false
SSH_CHANGED=false

install_packages
install_broker
validate_oidc
configure_broker_discovery
configure_broker
configure_sshd
restart_if_needed
show_effective_config