一个通过 Short Messaging Service 用于实现 SSH 两步验证(SSH Login Two-Factor Authentication)的 PAM 模块

就如前篇所说,在经过用Shell脚本编写的过后,还是决定改成用其他语言编写一个PAM模块来实现我所需要的功能吧!

说是SMS短信来实现两步验证的功能,但是受限于短信接口不仅需要钱且申请有些繁杂,这不就使用Gotify、Ntfy这些实时消息推送服务来实现需求。

其实这个类似的功能就有一款由Google开发的Google Authenticator就能实现,而且对于Google Authenticator这款App的使用也不是什么难题。

源码

pam_extra_auth.c

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <security/pam_appl.h>
#include <security/pam_modules.h>
#include <unistd.h>
#include <syslog.h>
#include <curl/curl.h>
#include <json-c/json.h>
#include <fcntl.h>
#include <errno.h>

// 配置参数默认值
#define DEFAULT_PROMPT "请输入接收到的验证码: "
#define DEFAULT_CONFIG_FILE "/etc/pam_extra_auth.conf"
#define DEFAULT_GOTIFY_URL "https://gotify.cyzwb.com/"
#define DEFAULT_GOTIFY_KEY "your_api_key_here"
#define DEFAULT_NTFY_URL "https://ntfy.cyzwb.com/"
#define DEFAULT_NTFY_KEY "your_api_key_here"
#define DEFAULT_NTFY_TOPIC "your_api_key_here"
#define CODE_EXPIRE_SECONDS 300  // 验证码有效期5分钟

typedef struct {
    char *prompt;         // 提示信息
    char *gotify_url;    // Gotify API地址
    char *gotify_key;    // Gotify API密钥
    char *ntfy_url;      // Ntfy API地址
    char *ntfy_key;      // Ntfy API密钥
    char *ntfy_topic;    // Ntfy TOPIC
} pam_extra_config_t;

// 用于存储HTTP响应
struct memory_struct {
    char *memory;
    size_t size;
};

/**
 * 加载配置文件
 */
static int load_config(pam_extra_config_t *config) {
    FILE *fp = fopen(DEFAULT_CONFIG_FILE, "r");
    if (!fp) {
        // 使用默认配置
        config->prompt = strdup(DEFAULT_PROMPT);
        config->gotify_url = strdup(DEFAULT_GOTIFY_URL);
        config->gotify_key = strdup(DEFAULT_GOTIFY_KEY);
        config->ntfy_url = strdup(DEFAULT_NTFY_URL);
        config->ntfy_key = strdup(DEFAULT_NTFY_KEY);
        config->ntfy_topic = strdup(DEFAULT_NTFY_TOPIC);
        return PAM_SUCCESS;
    }

    char line[256];
    while (fgets(line, sizeof(line), fp)) {
        // 跳过注释和空行
        if (line[0] == '#' || line[0] == '\n') continue;

        char key[64], value[192];
        if (sscanf(line, "%63s = %191[^\n]", key, value) == 2) {
            if (strcmp(key, "prompt") == 0) {
                free(config->prompt);
                config->prompt = strdup(value);
            } else if (strcmp(key, "gotify_url") == 0) {
                free(config->gotify_url);
                config->gotify_url = strdup(value);
            } else if (strcmp(key, "gotify_key") == 0) {
                free(config->gotify_key);
                config->gotify_key = strdup(value);
            } else if (strcmp(key, "ntfy_url") == 0) {
                free(config->ntfy_url);
                config->ntfy_url = strdup(value);
            } else if (strcmp(key, "ntfy_key") == 0) {
                free(config->ntfy_key);
                config->ntfy_key = strdup(value);
            } else if (strcmp(key, "ntfy_topic") == 0) {
                free(config->ntfy_topic);
                config->ntfy_topic = strdup(value);
            }
        }
    }
    fclose(fp);
    return PAM_SUCCESS;
}

/**
 * HTTP响应回调函数
 */
static size_t write_memory_callback(void *contents, size_t size, size_t nmemb, void *userp) {
    size_t realsize = size * nmemb;
    struct memory_struct *mem = (struct memory_struct *)userp;

    char *ptr = realloc(mem->memory, mem->size + realsize + 1);
    if(!ptr) {
        /* 内存分配失败 */
        syslog(LOG_ERR, "内存分配失败");
        return 0;
    }

    mem->memory = ptr;
    memcpy(&(mem->memory[mem->size]), contents, realsize);
    mem->size += realsize;
    mem->memory[mem->size] = 0;

    return realsize;
}

/**
 * 发起JSON格式的POST请求
 * @param url 请求URL
 * @param json_obj JSON对象
 * @param api_key 请求密钥
 * @return HTTP响应内容,需要调用者free释放
 */
static char* http_post_json(const char *url, json_object *json_obj, const char *api_key) {
    CURL *curl_handle;
    CURLcode res;
    struct memory_struct chunk;
    struct curl_slist *headers = NULL;
    const char *json_str = json_object_to_json_string(json_obj);

    // 初始化响应存储
    chunk.memory = malloc(1);
    chunk.size = 0;

    // 初始化libcurl
    curl_global_init(CURL_GLOBAL_ALL);
    curl_handle = curl_easy_init();

    if(curl_handle) {
        // 设置请求URL
        curl_easy_setopt(curl_handle, CURLOPT_URL, url);

        // 设置自定义请求头 - 包含API密钥和JSON内容类型
        char auth_header[128];
        snprintf(auth_header, sizeof(auth_header), "Authorization: Bearer %s", api_key);
        headers = curl_slist_append(headers, auth_header);
        headers = curl_slist_append(headers, "Content-Type: application/json");
        curl_easy_setopt(curl_handle, CURLOPT_HTTPHEADER, headers);

        // 设置POST数据(JSON字符串)
        curl_easy_setopt(curl_handle, CURLOPT_POSTFIELDS, json_str);
        curl_easy_setopt(curl_handle, CURLOPT_POSTFIELDSIZE, strlen(json_str));

        // 设置响应回调函数
        curl_easy_setopt(curl_handle, CURLOPT_WRITEFUNCTION, write_memory_callback);
        curl_easy_setopt(curl_handle, CURLOPT_WRITEDATA, (void *)&chunk);

        // 开启HTTPS支持
        curl_easy_setopt(curl_handle, CURLOPT_SSL_VERIFYPEER, 1L);
        curl_easy_setopt(curl_handle, CURLOPT_SSL_VERIFYHOST, 2L);

        // 设置超时时间
        curl_easy_setopt(curl_handle, CURLOPT_TIMEOUT, 10L);

        // 执行请求
        res = curl_easy_perform(curl_handle);

        // 检查请求是否成功
        if(res != CURLE_OK) {
            syslog(LOG_ERR, "curl请求失败: %s", curl_easy_strerror(res));
            free(chunk.memory);
            chunk.memory = NULL;
        }

        // 清理资源
        curl_easy_cleanup(curl_handle);
        curl_slist_free_all(headers);
    } else {
        syslog(LOG_ERR, "初始化curl失败");
        free(chunk.memory);
        chunk.memory = NULL;
    }

    curl_global_cleanup();
    return chunk.memory;
}

/**
 * 生成安全的6位验证码(使用/dev/urandom)
 */
static char* generate_code() {
    char *code = malloc(7);
    if (!code) {
        syslog(LOG_ERR, "内存分配失败");
        return NULL;
    }
    
    int urandom_fd = open("/dev/urandom", O_RDONLY);
    if (urandom_fd == -1) {
        syslog(LOG_ERR, "无法打开/dev/urandom: %s", strerror(errno));
        free(code);
        return NULL;
    }
    
    unsigned char bytes[6];
    ssize_t bytes_read = read(urandom_fd, bytes, sizeof(bytes));
    if (bytes_read != sizeof(bytes)) {
        syslog(LOG_ERR, "读取/dev/urandom失败: %s", strerror(errno));
        close(urandom_fd);
        free(code);
        return NULL;
    }
    
    for(int i = 0; i < 6; i++) {
        code[i] = '0' + (bytes[i] % 10);
    }
    code[6] = '\0';
    
    close(urandom_fd);
    return code;
}

/**
 * 生成并发送验证码
 */
static char* generate_and_send_code(const char *username, const pam_extra_config_t *config) {
    // 生成验证码
    char *code = generate_code();
    if (!code) {
        syslog(LOG_ERR, "生成验证码失败");
        return NULL;
    }
    
    char message[128];
    snprintf(message, sizeof(message), "用户 %s 的SSH登录验证码:%s(5分钟内有效)", username, code);
    syslog(LOG_INFO, "生成验证码: %s for user %s", code, username);
  
    // 发送验证码到Gotify
    {
        const char *url = config->gotify_url;
        const char *api_key = config->gotify_key;
  
        json_object *json_obj = json_object_new_object();
        json_object_object_add(json_obj, "title", json_object_new_string("SSH登录验证码"));
        json_object_object_add(json_obj, "priority", json_object_new_int(5));
        json_object_object_add(json_obj, "message", json_object_new_string(message));

        char *response = http_post_json(url, json_obj, api_key);
        if(response) {
            syslog(LOG_DEBUG, "Gotify响应: %s", response);
            free(response);
        } else {
            syslog(LOG_ERR, "Gotify请求失败");
        }

        json_object_put(json_obj);
    }
  
    // 发送验证码到Ntfy
    {
        const char *url = config->ntfy_url;
        const char *api_key = config->ntfy_key;
        const char *topic = config->ntfy_topic;
  
        json_object *json_obj = json_object_new_object();
        json_object_object_add(json_obj, "title", json_object_new_string("SSH登录验证码"));
        json_object_object_add(json_obj, "topic", json_object_new_string(topic));
        json_object_object_add(json_obj, "message", json_object_new_string(message));

        char *response = http_post_json(url, json_obj, api_key);
        if(response) {
            syslog(LOG_DEBUG, "Ntfy响应: %s", response);
            free(response);
        } else {
            syslog(LOG_ERR, "Ntfy请求失败");
        }

        json_object_put(json_obj);
    }
  
    return code;
}

/**
 * 验证逻辑 - 验证码比对+过期检查
 */
static int verify_code(const char *input, const char *expected_code, time_t generate_time) {
    if (!input || !expected_code) {
        return PAM_AUTH_ERR;
    }
    
    // 检查验证码是否过期
    if (time(NULL) - generate_time > CODE_EXPIRE_SECONDS) {
        syslog(LOG_WARNING, "验证码已过期");
        return PAM_AUTH_ERR;
    }
    
    return strcmp(input, expected_code) == 0 ? PAM_SUCCESS : PAM_AUTH_ERR;
}

/**
 * PAM认证函数
 */
PAM_EXTERN int pam_sm_authenticate(pam_handle_t *pamh, int flags, int argc, const char **argv) {
    int retval;
    const char *username;
    char *input = NULL;
    char *expected_code = NULL;
    time_t code_generate_time;
    pam_extra_config_t config = {0};
    struct pam_conv *conv;
    const char *session_reuse_env = NULL;

    openlog("pam_extra_auth", LOG_PID | LOG_CONS, LOG_AUTH);

    // 静默未使用参数,避免编译错误
    (void)flags; (void)argc; (void)argv;

    // 检查是否是Session复用请求
    session_reuse_env = pam_getenv(pamh, "SSH_SESSION_REUSE");
    if (session_reuse_env != NULL && strcmp(session_reuse_env, "1") == 0) {
        syslog(LOG_INFO, "Session复用请求,跳过验证码验证");
        closelog();
        return PAM_SUCCESS;
    }

    // 获取登录用户名
    retval = pam_get_user(pamh, &username, "用户名: ");
    if (retval != PAM_SUCCESS) {
        syslog(LOG_ERR, "无法获取用户名: %s", pam_strerror(pamh, retval));
        closelog();
        return retval;
    }

    // 加载配置
    load_config(&config);

    // 获取对话函数
    retval = pam_get_item(pamh, PAM_CONV, (const void **)&conv);
    if (retval != PAM_SUCCESS || !conv) {
        syslog(LOG_ERR, "无法获取对话函数: %s", pam_strerror(pamh, retval));
        goto cleanup;
    }

    // =========== 核心修复:先生成并发送验证码 ===========
    expected_code = generate_and_send_code(username, &config);
    if (!expected_code) {
        syslog(LOG_ERR, "生成并发送验证码失败");
        retval = PAM_SYSTEM_ERR;
        goto cleanup;
    }
    code_generate_time = time(NULL);

    // =========== 再弹出输入框要求用户输入 ===========
    struct pam_message msg = {
        .msg_style = PAM_PROMPT_ECHO_OFF,
        .msg = config.prompt
    };
    const struct pam_message *msgp = &msg;
    struct pam_response *resp = NULL;

    retval = conv->conv(1, &msgp, &resp, conv->appdata_ptr);
    if (retval != PAM_SUCCESS || !resp) {
        syslog(LOG_ERR, "获取用户输入失败: %s", pam_strerror(pamh, retval));
        goto cleanup;
    }
    input = resp->resp;

    // 执行认证逻辑
    retval = verify_code(input, expected_code, code_generate_time);

    if (retval == PAM_SUCCESS) {
        syslog(LOG_INFO, "用户 %s 验证码验证通过", username);
    } else {
        syslog(LOG_WARNING, "用户 %s 验证码验证失败", username);
    }

cleanup:
    // 清理资源
    free(config.prompt);
    free(config.gotify_url);
    free(config.gotify_key);
    free(config.ntfy_url);
    free(config.ntfy_key);
    free(config.ntfy_topic);
    free(expected_code);
    if (resp) {
        if (resp->resp) free(resp->resp);
        free(resp);
    }
    closelog();
    return retval;
}

/**
 * 修复pam_sm_setcred实现
 */
PAM_EXTERN int pam_sm_setcred(pam_handle_t *pamh, int flags, int argc, const char **argv) {
    openlog("pam_extra_auth", LOG_PID | LOG_CONS, LOG_AUTH);
    
    // 静默未使用参数,避免编译警告
    (void)pamh; (void)argc; (void)argv;
    
    // 检查标志是否有效
    if (flags & ~(PAM_SILENT | PAM_ESTABLISH_CRED | PAM_DELETE_CRED | 
                  PAM_REINITIALIZE_CRED | PAM_REFRESH_CRED)) {
        syslog(LOG_ERR, "无效的标志位: %d", flags);
        closelog();
        return PAM_SYSTEM_ERR;  // 替代PAM_BAD_CONSTANT,兼容旧版本PAM库
    }
    
    // 对于我们的验证码模块,不需要设置任何凭据
    // 只需要根据标志返回相应的成功状态
    switch (flags & ~PAM_SILENT) {
        case PAM_ESTABLISH_CRED:
        case PAM_REINITIALIZE_CRED:
        case PAM_REFRESH_CRED:
            syslog(LOG_INFO, "凭据设置成功");
            closelog();
            return PAM_SUCCESS;
            
        case PAM_DELETE_CRED:
            syslog(LOG_INFO, "凭据删除成功");
            closelog();
            return PAM_SUCCESS;
            
        default:
            syslog(LOG_ERR, "不支持的标志位: %d", flags);
            closelog();
            return PAM_SYSTEM_ERR;
    }
}

// 其他PAM接口函数保持不变
PAM_EXTERN int pam_sm_acct_mgmt(pam_handle_t *pamh, int flags, int argc, const char **argv) {
    (void)pamh; (void)flags; (void)argc; (void)argv;
    return PAM_SUCCESS;
}

PAM_EXTERN int pam_sm_open_session(pam_handle_t *pamh, int flags, int argc, const char **argv) {
    (void)pamh; (void)flags; (void)argc; (void)argv;
    return PAM_SUCCESS;
}

PAM_EXTERN int pam_sm_close_session(pam_handle_t *pamh, int flags, int argc, const char **argv) {
    (void)pamh; (void)flags; (void)argc; (void)argv;
    return PAM_SUCCESS;
}

PAM_EXTERN int pam_sm_chauthtok(pam_handle_t *pamh, int flags, int argc, const char **argv) {
    (void)pamh; (void)flags; (void)argc; (void)argv;
    return PAM_SUCCESS;
}

// 模块符号定义
#ifdef PAM_STATIC
struct pam_module _pam_extra_auth_modstruct = {
    "pam_extra_auth",
    pam_sm_authenticate,
    pam_sm_acct_mgmt,
    pam_sm_setcred,
    pam_sm_open_session,
    pam_sm_close_session,
    pam_sm_chauthtok
};
#endif

pam_extra_auth.conf

# 提示信息
prompt = 请输入接收到的验证码: 
# Gotify API地址
gotify_url = https://gotify.cyzwb.com/message
# Gotify API密钥
gotify_key = AxxxxxxxR
# Ntfy API地址
ntfy_url = https://ntfy.cyzwb.com
# Ntfy API密钥
ntfy_key = tk_8xxxxi
# Ntfy TOPIC
ntfy_topic = 2xxxf

Makefile

CC = gcc
CFLAGS = -fPIC -Wall -Wextra -Werror=implicit-function-declaration
LIBS = -lpam -lcurl -ljson-c

all: pam_extra_auth.so

pam_extra_auth.so: pam_extra_auth.c
	$(CC) $(CFLAGS) -shared -o $@ $^ $(LIBS)

install: pam_extra_auth.so
	install -m 755 pam_extra_auth.so /lib/security/
	install -m 600 pam_extra_auth.conf /etc/

clean:
	rm -f pam_extra_auth.so

编译并安装

make
make install

启用

  1. 编辑/etc/pam.d/sshd文件,添加如下语句:
    # Standard Un*x authentication.
    @include common-auth
    
    ### 自定义SSH登录验证 2026-05-12 1352
    auth    required    pam_extra_auth.so
    
    # Disallow non-root logins when /etc/nologin exists.
    account    required     pam_nologin.so
    
    

    注意顺序,我预期值是先输入用户密码才发送接收验证码来输入。

  2. 编辑/etc/ssh/sshd_config文件,将如下参数设置为下列所示:
    ### 2026-05-12
    PasswordAuthentication yes
    KbdInteractiveAuthentication yes  # 启用键盘交互认证
    ChallengeResponseAuthentication yes  # 启用挑战-响应认证
    
    

    貌似需要配置键盘交互认证,这样xshell之类的软件才能使用。

总结

在一天的使用中未发现有什么较大的问题,但根据其原理还是知晓有几处问题的。

  • 网络出问题时就无法登录进行维护了,至少对于Gotify、ntfy等服务端来说宕机了还能靠迁移恢复来接收验证码。但是若是机器的出网网络出问题的时候,需要登录进去维护网络是就陷入死循环了。

ChiuYut

2026年05月14日

发布者

ChiuYut

咦?我是谁?这是什么地方? Ya ha!我是ChiuYut!这里是我的小破站!