add login and register

This commit is contained in:
hanyixuanten
2026-08-09 20:39:23 +08:00
parent 447cc628cc
commit 5cd26c9f02
10 changed files with 816 additions and 83 deletions
+1
View File
@@ -1,5 +1,6 @@
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteCond %{REQUEST_URI} !index\.php$
RewriteRule .* index.php [L,QSA]
</IfModule>
+16 -10
View File
@@ -8,6 +8,8 @@ This project serves configured Git repositories through PHP. It supports:
- Smart HTTP `upload-pack` for clone, fetch and pull.
- Smart HTTP `receive-pack` for push.
- Remote branch and tag creation, update and deletion through push.
- MySQL-backed account registration and login.
- Hashed, revocable access tokens for authenticated Git pushes.
- Authenticated bare-repository creation from the home page.
- Per-repository controls for reads, pushes, authentication, branch refs,
tag refs, other ref namespaces and push request size.
@@ -22,6 +24,8 @@ Requirements
- PHP 7.4 or newer.
- Apache with `mod_rewrite` and `.htaccess` enabled for normal deployment.
- MySQL 5.7+/MariaDB 10.2+ and PHP PDO MySQL (`pdo_mysql`) when account
authentication is enabled.
- PHP zlib and hash extensions for the native Git protocol implementation.
- Git and PHP `proc_open` are optional; when available, Git remains the Smart
HTTP backend for full protocol and hook compatibility.
@@ -38,9 +42,9 @@ web-server authentication.
Configuration
-------------
Copy `config.php.sample` to `config.php`, then configure `$url_base`,
`$git_executable`, `$repos` and optionally `$managed_repositories`. A writable
repository can be configured as:
Copy `config.php.sample` to `config.php`, import `schema.mysql.sql`, then
configure `$url_base`, `$git_executable`, `$auth`, `$repos` and optionally
`$managed_repositories`. A writable repository can be configured as:
```php
$repos = array(
@@ -53,9 +57,11 @@ $repos = array(
'other_refs' => FALSE)));
```
`require_auth` trusts only `REMOTE_USER`, which must be set by authenticated
Apache or reverse-proxy configuration. Setting it to `FALSE` permits anonymous
push and is suitable only for isolated development environments.
With `require_auth` enabled, Git uses HTTP Basic authentication: enter the
registered username as the username and an access token as the password. The
application stores password hashes and token SHA-256 digests in MySQL; token
plaintext is shown only once. Setting `require_auth` to `FALSE` permits
anonymous push and is suitable only for isolated development environments.
To create bare repositories from the home page, enable managed repositories.
They are always stored in the application's `repos` directory and discovered
@@ -64,19 +70,19 @@ automatically; this path cannot be changed in `config.php`:
```php
$managed_repositories = array(
'require_auth' => TRUE,
'session_cookie_secure' => TRUE,
'options' => array(
'read' => TRUE,
'push' => TRUE,
'require_auth' => TRUE));
```
The home page uses the logged-in application session for repository creation.
The `repos` directory is created automatically when missing. Its parent must be
writable for that first creation, and the resulting directory must be readable,
writable and searchable by PHP and reserved for this application. Set
`session_cookie_secure` to `TRUE` when HTTPS terminates at a trusted reverse
proxy; direct HTTPS deployments are detected from the web-server connection
automatically.
`$auth['session_cookie_secure']` to `TRUE` when HTTPS terminates at a trusted
reverse proxy; direct HTTPS deployments are detected from the web-server
connection automatically.
See `usage.md` for complete Chinese installation, configuration, operation and
security instructions.
+17 -3
View File
@@ -11,6 +11,22 @@ $url_base = '';
$git_executable = 'git';
/*
* Application accounts and access tokens. Import schema.mysql.sql before
* enabling this section. Use a dedicated MySQL user with SELECT, INSERT and
* UPDATE privileges on these tables; DELETE and schema privileges are not
* required by the application.
*/
$auth = array(
'enabled' => TRUE,
'registration_enabled' => TRUE,
/* Set to TRUE when HTTPS terminates at a trusted reverse proxy. */
// 'session_cookie_secure' => TRUE,
'database' => array(
'dsn' => 'mysql:host=127.0.0.1;port=3306;dbname=php_git_server;charset=utf8mb4',
'username' => 'php_git_server',
'password' => 'replace-with-a-long-random-password'));
/*
* Optional settings for repositories created from the home page.
* Repositories are always stored in this application's repos directory. It is
@@ -19,8 +35,6 @@ $git_executable = 'git';
*/
$managed_repositories = array(
'require_auth' => TRUE,
/* Uncomment when TLS ends at a trusted reverse proxy. */
// 'session_cookie_secure' => TRUE,
'options' => array(
'read' => TRUE,
'push' => TRUE,
@@ -38,7 +52,7 @@ $managed_repositories = array(
*
* read Allow clone, fetch and pull. Default: TRUE.
* push Allow Smart HTTP push. Default: FALSE.
* require_auth Require a trusted REMOTE_USER for push. Default: TRUE.
* require_auth Require an application access token for push. Default: TRUE.
* branches Allow push updates under refs/heads/. Default: TRUE.
* tags Allow push updates under refs/tags/. Default: TRUE.
* other_refs Allow push updates to other ref namespaces. Default: FALSE.
+242 -28
View File
@@ -2,6 +2,7 @@
require(__DIR__.'/config.php');
require(__DIR__.'/lib/http.php');
require(__DIR__.'/lib/auth.php');
require(__DIR__.'/lib/repository.php');
require(__DIR__.'/lib/router.php');
require(__DIR__.'/lib/git_protocol.php');
@@ -55,6 +56,9 @@ function home_session_cookie_is_secure($configuration) {
}
function home_start_session($url_base, $configuration) {
if (auth_is_enabled()) {
return auth_start_session();
}
if (session_status() === PHP_SESSION_ACTIVE) {
return TRUE;
}
@@ -100,6 +104,123 @@ function home_take_notice($url_base, $configuration) {
return is_array($notice) ? $notice : NULL;
}
function home_set_notice($url_base, $configuration, $type, $message, $token=NULL) {
if (!home_start_session($url_base, $configuration)) {
return FALSE;
}
$_SESSION['home_notice'] = array('type' => $type, 'message' => $message);
if ($token !== NULL) {
$_SESSION['home_notice']['token'] = $token;
}
return TRUE;
}
function home_redirect($url_base) {
send_status(303, 'See Other');
header('Location: '.home_page_url($url_base));
die();
}
function home_post_value($name) {
return isset($_POST[$name]) && is_string($_POST[$name]) ? $_POST[$name] : '';
}
function home_require_csrf($url_base, $configuration) {
if (!request_content_type_is(get_request_header('Content-Type'), 'application/x-www-form-urlencoded')) {
send_error(415, 'Unsupported Media Type', 'Expected a form-encoded request.');
}
$expected_token = home_csrf_token($url_base, $configuration);
if ($expected_token === FALSE
|| !hash_equals($expected_token, home_post_value('csrf_token'))) {
send_error(403, 'Forbidden', 'The form security token is invalid.');
}
}
function home_auth_result_notice($result) {
switch ($result['status']) {
case 'registered':
return array('success', '账号已注册并登录。');
case 'logged_in':
return array('success', '登录成功。');
case 'registration_disabled':
return array('error', '当前不允许注册新账号。');
case 'invalid_username':
return array('error', '用户名须为 3 至 64 个字母、数字、点、短横线或下划线。');
case 'invalid_password':
return array('error', '密码长度须为 12 至 72 个字符,且不能超过 72 字节。');
case 'password_mismatch':
return array('error', '两次输入的密码不一致。');
case 'username_exists':
return array('error', '该用户名已被注册。');
case 'invalid_credentials':
return array('error', '用户名或密码错误。');
case 'invalid_token_name':
return array('error', 'Token 名称不能为空,且最多 80 个字符。');
case 'token_created':
return array('success', 'Access token 已创建。请立即保存,关闭页面后无法再次查看。');
case 'token_revoked':
return array('success', 'Access token 已撤销。');
case 'invalid_token':
return array('error', 'Access token 不存在或已撤销。');
case 'session_unavailable':
return array('error', '当前无法建立安全会话。');
default:
return array('error', '认证数据库当前不可用。');
}
}
function home_handle_auth_action($url_base, $configuration, $action) {
if (!auth_is_enabled()) {
send_error(404, 'Not Found', 'Account authentication is disabled.');
}
home_require_csrf($url_base, $configuration);
$session_user = auth_session_user();
if ($action === 'register') {
if ($session_user !== NULL) {
send_error(409, 'Conflict', 'Already authenticated.');
}
$result = auth_register(
home_post_value('username'),
home_post_value('password'),
home_post_value('password_confirmation'));
} else if ($action === 'login') {
if ($session_user !== NULL) {
send_error(409, 'Conflict', 'Already authenticated.');
}
$result = auth_login(home_post_value('username'), home_post_value('password'));
} else if ($action === 'logout') {
if ($session_user === NULL) {
send_error(403, 'Forbidden', 'Login is required.');
}
if (!auth_logout()) {
send_error(500, 'Internal Server Error', 'Unable to close the login session.');
}
home_set_notice($url_base, $configuration, 'success', '已退出登录。');
home_redirect($url_base);
} else if ($action === 'create_token') {
if ($session_user === NULL) {
send_error(403, 'Forbidden', 'Login is required.');
}
$result = auth_create_access_token($session_user['id'], home_post_value('token_name'));
} else if ($action === 'revoke_token') {
if ($session_user === NULL) {
send_error(403, 'Forbidden', 'Login is required.');
}
$result = auth_revoke_access_token($session_user['id'], home_post_value('token_id'));
} else {
send_error(400, 'Bad Request', 'Unknown account action.');
}
$notice = home_auth_result_notice($result);
$plaintext_token = isset($result['token']) ? $result['token'] : NULL;
home_set_notice($url_base, $configuration, $notice[0], $notice[1], $plaintext_token);
home_redirect($url_base);
}
function home_repository_url_exists($url_base, $definitions, $name) {
$expected_url = rtrim((string) $url_base, '/').'/'.$name;
foreach ($definitions as $definition) {
@@ -141,16 +262,7 @@ function home_create_repository(
if (!home_creation_is_authorized($configuration)) {
send_error(403, 'Forbidden', 'Authenticated access is required to create repositories.');
}
if (!request_content_type_is(get_request_header('Content-Type'), 'application/x-www-form-urlencoded')) {
send_error(415, 'Unsupported Media Type', 'Expected a form-encoded request.');
}
$token = isset($_POST['csrf_token']) && is_string($_POST['csrf_token'])
? $_POST['csrf_token'] : '';
$expected_token = home_csrf_token($url_base, $configuration);
if ($expected_token === FALSE || !hash_equals($expected_token, $token)) {
send_error(403, 'Forbidden', 'The form security token is invalid.');
}
home_require_csrf($url_base, $configuration);
$value = isset($_POST['repository_name']) && is_string($_POST['repository_name'])
? $_POST['repository_name'] : '';
@@ -162,12 +274,9 @@ function home_create_repository(
}
if ($result['status'] === 'created') {
$_SESSION['home_notice'] = array(
'type' => 'success',
'message' => '仓库 '.$result['name'].' 已创建。');
send_status(303, 'See Other');
header('Location: '.home_page_url($url_base));
die();
home_set_notice(
$url_base, $configuration, 'success', '仓库 '.$result['name'].' 已创建。');
home_redirect($url_base);
}
$notice = home_creation_result_notice($result);
@@ -281,10 +390,12 @@ h1 { margin: 0 0 .25rem; font-size: 1.5rem; }
h2 { margin: 2.5rem 0 .5rem; font-size: 1.1rem; }
p { margin: 0 0 1rem; }
.lead { color: #5b6472; }
.create { margin: 1.75rem 0 2rem; padding: 1rem 0; border-top: 1px solid #d5dae1;
.account, .create { margin: 1.75rem 0 2rem; padding: 1rem 0; border-top: 1px solid #d5dae1;
border-bottom: 1px solid #d5dae1; }
.create h2 { margin: 0 0 .25rem; }
.create form { display: flex; gap: .6rem; align-items: end; }
.account h2, .create h2 { margin: 0 0 .25rem; }
.account-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.5rem; }
.account form, .create form, .token-form { display: flex; gap: .6rem; align-items: end; }
.account .credentials { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem; flex: 1; }
.field { flex: 1 1 22rem; }
label { display: block; margin-bottom: .3rem; font-weight: 600; }
input { box-sizing: border-box; width: 100%; min-height: 2.6rem; padding: .5rem .7rem;
@@ -295,6 +406,15 @@ button { min-height: 2.6rem; padding: .5rem 1rem; border: 1px solid #175b3a;
border-radius: .35rem; color: #fff; background: #176b43; font: inherit;
font-weight: 600; cursor: pointer; }
button:hover { background: #125635; }
.button-danger { border-color: #983434; background: #a43a3a; }
.button-danger:hover { background: #842e2e; }
.account-bar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
.account-bar form, .token-list form { display: block; }
.account-bar button, .token-list button { width: auto; margin: 0; }
.token-result { user-select: all; }
.token-list { margin: 1rem 0 0; padding: 0; list-style: none; }
.token-list li { display: flex; justify-content: space-between; gap: 1rem; align-items: center;
padding: .65rem 0; border-bottom: 1px solid #d5dae1; }
.hint { margin: .4rem 0 0; color: #5b6472; font-size: .9rem; }
.notice { margin: 1rem 0; padding: .65rem .8rem; border-left: .25rem solid; }
.notice-success { border-color: #1f7a4b; background: #edf8f1; color: #155735; }
@@ -319,14 +439,16 @@ pre { padding: .75rem 1rem; overflow-x: auto; border: 1px solid #d5dae1;
footer { margin-top: 2.5rem; color: #5b6472; font-size: .9rem; }
@media (max-width: 42rem) {
body { padding-top: 1.5rem; }
.create form { display: block; }
.account-grid, .account .credentials { display: block; }
.account form, .create form, .token-form { display: block; }
button { width: 100%; margin-top: .65rem; }
.account-bar button, .token-list button { width: auto; margin-top: 0; }
table { display: block; overflow-x: auto; }
}
@media (prefers-color-scheme: dark) {
body { color: #e6e9ef; background: #12161c; }
.lead, .hint, caption, footer, .badge-quiet, .empty { color: #9aa4b2; }
.create, th, td { border-color: #2b323d; }
.account, .create, th, td, .token-list li { border-color: #2b323d; }
input { border-color: #596474; background: #1a1f27; color: #e6e9ef; }
pre { border-color: #2b323d; background: #1a1f27; }
.empty { border-color: #3a424f; }
@@ -341,6 +463,84 @@ footer { margin-top: 2.5rem; color: #5b6472; font-size: .9rem; }
HTML;
}
function home_send_authentication($url_base, $configuration, $notice) {
if (!auth_is_enabled()) {
return;
}
echo '<section class="account" aria-labelledby="account-title">' ."\n";
echo '<h2 id="account-title">账号与 Access Token</h2>' ."\n";
if ($notice !== NULL && isset($notice['token']) && is_string($notice['token'])) {
echo '<pre class="token-result"><code>'.home_escape($notice['token']).'</code></pre>' ."\n";
}
$csrf_token = home_csrf_token($url_base, $configuration);
if ($csrf_token === FALSE) {
echo '<p class="notice notice-error" role="status">当前无法初始化安全表单。</p>' ."\n";
echo '</section>' ."\n";
return;
}
$user = auth_session_user();
$action = home_escape(home_page_url($url_base));
$csrf_field = '<input type="hidden" name="csrf_token" value="'
.home_escape($csrf_token).'">' ."\n";
if ($user === NULL) {
echo '<div class="account-grid">' ."\n";
echo '<form method="post" action="'.$action.'">' ."\n".$csrf_field;
echo '<input type="hidden" name="action" value="login">' ."\n";
echo '<div class="credentials"><div><label for="login-username">用户名</label>' ."\n";
echo '<input id="login-username" name="username" maxlength="64" autocomplete="username" required></div>' ."\n";
echo '<div><label for="login-password">密码</label>' ."\n";
echo '<input id="login-password" name="password" type="password" minlength="12" maxlength="72" autocomplete="current-password" required></div></div>' ."\n";
echo '<button type="submit">登录</button></form>' ."\n";
if (auth_registration_is_enabled()) {
echo '<form method="post" action="'.$action.'">' ."\n".$csrf_field;
echo '<input type="hidden" name="action" value="register">' ."\n";
echo '<div class="credentials"><div><label for="register-username">注册用户名</label>' ."\n";
echo '<input id="register-username" name="username" minlength="3" maxlength="64" pattern="[A-Za-z0-9][A-Za-z0-9._-]*[A-Za-z0-9_-]" autocomplete="username" required></div>' ."\n";
echo '<div><label for="register-password">密码</label>' ."\n";
echo '<input id="register-password" name="password" type="password" minlength="12" maxlength="72" autocomplete="new-password" required></div>' ."\n";
echo '<div><label for="register-password-confirmation">确认密码</label>' ."\n";
echo '<input id="register-password-confirmation" name="password_confirmation" type="password" minlength="12" maxlength="72" autocomplete="new-password" required></div></div>' ."\n";
echo '<button type="submit">注册</button></form>' ."\n";
}
echo '</div>' ."\n";
echo '<p class="hint">网页登录使用密码;Git clone、pull 和 push 使用用户名与 access token。</p>' ."\n";
echo '</section>' ."\n";
return;
}
echo '<div class="account-bar"><p>当前账号:<strong>'.home_escape($user['username']).'</strong></p>' ."\n";
echo '<form method="post" action="'.$action.'">'.$csrf_field;
echo '<input type="hidden" name="action" value="logout">' ."\n";
echo '<button class="button-danger" type="submit">退出</button></form></div>' ."\n";
echo '<form class="token-form" method="post" action="'.$action.'">'.$csrf_field;
echo '<input type="hidden" name="action" value="create_token">' ."\n";
echo '<div class="field"><label for="token-name">新 Token 名称</label>' ."\n";
echo '<input id="token-name" name="token_name" maxlength="80" placeholder="工作电脑" required></div>' ."\n";
echo '<button type="submit">创建 Token</button></form>' ."\n";
$tokens = auth_list_access_tokens($user['id']);
if ($tokens === FALSE) {
echo '<p class="notice notice-error">当前无法读取 access token 列表。</p>' ."\n";
} else if (!empty($tokens)) {
echo '<ul class="token-list">' ."\n";
foreach ($tokens as $token) {
$last_used = $token['last_used_at'] === NULL ? '从未使用' : '最后使用 '.$token['last_used_at'];
echo '<li><span><strong>'.home_escape($token['name']).'</strong><br>';
echo '<span class="hint">创建于 '.home_escape($token['created_at']).''.home_escape($last_used).'</span></span>' ."\n";
echo '<form method="post" action="'.$action.'">'.$csrf_field;
echo '<input type="hidden" name="action" value="revoke_token">' ."\n";
echo '<input type="hidden" name="token_id" value="'.home_escape($token['id']).'">' ."\n";
echo '<button class="button-danger" type="submit">撤销</button></form></li>' ."\n";
}
echo '</ul>' ."\n";
}
echo '</section>' ."\n";
}
function home_send_creation($url_base, $configuration, $notice, $value) {
if (!home_managed_repositories_configured($configuration)) {
return;
@@ -354,7 +554,7 @@ function home_send_creation($url_base, $configuration, $notice, $value) {
}
if (!home_creation_is_authorized($configuration)) {
echo '<p class="lead">需要先通过 Web 服务器身份验证,才能创建仓库。</p>' ."\n";
echo '<p class="lead">需要先登录应用账号,才能创建仓库。</p>' ."\n";
echo '</section>' ."\n";
return;
}
@@ -433,7 +633,7 @@ function home_send_usage($repositories, $prefix) {
.'git pull'."\n"
.'git push origin main</code></pre>'."\n";
echo '<p class="lead">push 需要仓库启用 <code>push</code> 选项;启用认证时还需要'
.'由 Web 服务器完成身份验证。</p>'."\n";
.'使用账号用户名和 access token。</p>'."\n";
}
function home_render(
@@ -451,7 +651,12 @@ function home_render(
header('Content-Security-Policy: default-src \'none\'; style-src \'unsafe-inline\'');
home_send_head();
home_send_creation($url_base, $configuration, $notice, $value);
if ($notice !== NULL) {
echo '<p class="notice notice-'.home_escape($notice['type']).'" role="status">'
.home_escape($notice['message']).'</p>' ."\n";
}
home_send_authentication($url_base, $configuration, $notice);
home_send_creation($url_base, $configuration, NULL, $value);
if (empty($repositories)) {
home_send_empty_notice();
@@ -469,6 +674,10 @@ function home_dispatch($url_base, $definitions, $configuration, $application) {
$method = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : 'GET';
if ($method === 'POST') {
$action = home_post_value('action');
if ($action !== '' && $action !== 'create_repository') {
home_handle_auth_action($url_base, $configuration, $action);
}
home_create_repository($url_base, $definitions, $configuration, $application);
}
@@ -480,10 +689,7 @@ function home_dispatch($url_base, $definitions, $configuration, $application) {
die();
}
$notice = $method === 'GET'
&& home_managed_repositories_configured($configuration)
&& home_creation_is_authorized($configuration)
? home_take_notice($url_base, $configuration) : NULL;
$notice = $method === 'GET' ? home_take_notice($url_base, $configuration) : NULL;
home_render($url_base, $definitions, $configuration, $notice);
die();
}
@@ -500,6 +706,14 @@ if (!isset($git_executable)) {
$git_executable = 'git';
}
if (!isset($auth)) {
$auth = array();
}
if (!is_array($auth)) {
send_error(500, 'Internal Server Error', 'The authentication configuration is invalid.');
}
auth_configure($auth, $url_base);
if (!isset($managed_repositories)) {
$managed_repositories = array();
}
+429
View File
@@ -0,0 +1,429 @@
<?php
$auth_configuration = array();
$auth_url_base = '';
$auth_cached_user_resolved = FALSE;
$auth_cached_user = NULL;
function auth_configure($configuration, $url_base) {
global $auth_configuration, $auth_url_base;
global $auth_cached_user_resolved, $auth_cached_user;
$auth_configuration = is_array($configuration) ? $configuration : array();
$auth_url_base = (string) $url_base;
$auth_cached_user_resolved = FALSE;
$auth_cached_user = NULL;
}
function auth_is_enabled() {
global $auth_configuration;
return !empty($auth_configuration)
&& (!isset($auth_configuration['enabled']) || $auth_configuration['enabled'] === TRUE);
}
function auth_registration_is_enabled() {
global $auth_configuration;
return auth_is_enabled()
&& (!isset($auth_configuration['registration_enabled'])
|| $auth_configuration['registration_enabled'] === TRUE);
}
function auth_session_cookie_is_secure() {
global $auth_configuration;
if (isset($auth_configuration['session_cookie_secure'])) {
return $auth_configuration['session_cookie_secure'] === TRUE;
}
$https = isset($_SERVER['HTTPS']) ? strtolower((string) $_SERVER['HTTPS']) : '';
return $https === 'on' || $https === '1'
|| (isset($_SERVER['SERVER_PORT']) && (string) $_SERVER['SERVER_PORT'] === '443');
}
function auth_session_path() {
global $auth_url_base;
$base = rtrim($auth_url_base, '/');
return $base === '' ? '/' : $base.'/';
}
function auth_start_session() {
if (session_status() === PHP_SESSION_ACTIVE) {
return TRUE;
}
if (session_status() === PHP_SESSION_DISABLED || headers_sent()) {
return FALSE;
}
session_name('PHPGITSERVER');
session_set_cookie_params(array(
'lifetime' => 0,
'path' => auth_session_path(),
'secure' => auth_session_cookie_is_secure(),
'httponly' => TRUE,
'samesite' => 'Strict'));
return @session_start();
}
function auth_database() {
global $auth_configuration;
static $connection = NULL;
static $connection_key = NULL;
if (!auth_is_enabled()) {
return FALSE;
}
$database = isset($auth_configuration['database'])
&& is_array($auth_configuration['database'])
? $auth_configuration['database'] : array();
$dsn = isset($database['dsn']) ? $database['dsn'] : '';
$username = isset($database['username']) ? $database['username'] : '';
$password = isset($database['password']) ? $database['password'] : '';
if (!is_string($dsn) || $dsn === ''
|| !is_string($username) || !is_string($password)) {
error_log('Authentication database configuration is invalid.');
return FALSE;
}
$key = hash('sha256', $dsn."\0".$username);
if ($connection instanceof PDO && $connection_key === $key) {
return $connection;
}
try {
$connection = new PDO($dsn, $username, $password, array(
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => FALSE));
$connection_key = $key;
return $connection;
} catch (PDOException $exception) {
error_log('Authentication database connection failed: '.$exception->getMessage());
return FALSE;
}
}
function auth_normalize_username($value) {
if (!is_string($value)) {
return FALSE;
}
$username = trim($value);
if (!preg_match('~^(?=.{3,64}$)[A-Za-z0-9][A-Za-z0-9._-]*[A-Za-z0-9_-]$~D', $username)) {
return FALSE;
}
return $username;
}
function auth_password_is_valid($password) {
return is_string($password)
&& strlen($password) <= 72
&& preg_match('~^.{12,72}$~usD', $password) === 1;
}
function auth_find_active_user_by_id($database, $user_id) {
$statement = $database->prepare(
'SELECT id, username FROM pgit_users WHERE id = ? AND is_active = 1 LIMIT 1');
$statement->execute(array($user_id));
$user = $statement->fetch();
return $user === FALSE ? NULL : $user;
}
function auth_session_user() {
if (!auth_is_enabled()) {
return NULL;
}
if (session_status() !== PHP_SESSION_ACTIVE
&& (!isset($_COOKIE['PHPGITSERVER']) || !is_string($_COOKIE['PHPGITSERVER']))) {
return NULL;
}
if (!auth_start_session()
|| !isset($_SESSION['auth_user_id']) || !is_int($_SESSION['auth_user_id'])) {
return NULL;
}
$database = auth_database();
if ($database === FALSE) {
return NULL;
}
try {
$user = auth_find_active_user_by_id($database, $_SESSION['auth_user_id']);
if ($user === NULL) {
unset($_SESSION['auth_user_id']);
}
return $user;
} catch (PDOException $exception) {
error_log('Authentication session lookup failed: '.$exception->getMessage());
return NULL;
}
}
function auth_basic_credentials() {
if (isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) {
return array((string) $_SERVER['PHP_AUTH_USER'], (string) $_SERVER['PHP_AUTH_PW']);
}
$authorization = get_request_header('Authorization');
if (!is_string($authorization)
|| !preg_match('~^Basic[ \t]+([A-Za-z0-9+/]+={0,2})$~iD', trim($authorization), $matches)) {
return NULL;
}
$decoded = base64_decode($matches[1], TRUE);
if ($decoded === FALSE || strpos($decoded, ':') === FALSE) {
return NULL;
}
return explode(':', $decoded, 2);
}
function auth_token_user() {
if (!auth_is_enabled()) {
return NULL;
}
$credentials = auth_basic_credentials();
if ($credentials === NULL) {
return NULL;
}
$username = auth_normalize_username($credentials[0]);
$token = $credentials[1];
if ($username === FALSE || !preg_match('~^pgs_[a-f0-9]{64}$~D', $token)) {
return NULL;
}
$database = auth_database();
if ($database === FALSE) {
return NULL;
}
try {
$statement = $database->prepare(
'SELECT pgit_users.id, pgit_users.username, pgit_access_tokens.id AS token_id '
.'FROM pgit_access_tokens JOIN pgit_users '
.'ON pgit_users.id = pgit_access_tokens.user_id '
.'WHERE pgit_users.username = ? AND pgit_users.is_active = 1 '
.'AND pgit_access_tokens.token_hash = ? AND pgit_access_tokens.revoked_at IS NULL '
.'AND (pgit_access_tokens.expires_at IS NULL '
.'OR pgit_access_tokens.expires_at > CURRENT_TIMESTAMP) '
.'LIMIT 1');
$statement->execute(array($username, hash('sha256', $token)));
$user = $statement->fetch();
if ($user === FALSE) {
return NULL;
}
$update = $database->prepare(
'UPDATE pgit_access_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?');
$update->execute(array($user['token_id']));
unset($user['token_id']);
return $user;
} catch (PDOException $exception) {
error_log('Access token lookup failed: '.$exception->getMessage());
return NULL;
}
}
function auth_register($username_value, $password, $password_confirmation) {
if (!auth_registration_is_enabled()) {
return array('status' => 'registration_disabled');
}
$username = auth_normalize_username($username_value);
if ($username === FALSE) {
return array('status' => 'invalid_username');
}
if (!auth_password_is_valid($password)) {
return array('status' => 'invalid_password');
}
if (!is_string($password_confirmation) || !hash_equals($password, $password_confirmation)) {
return array('status' => 'password_mismatch');
}
$database = auth_database();
if ($database === FALSE) {
return array('status' => 'database_unavailable');
}
try {
$statement = $database->prepare(
'INSERT INTO pgit_users (username, password_hash) VALUES (?, ?)');
$statement->execute(array($username, password_hash($password, PASSWORD_DEFAULT)));
$user_id = (int) $database->lastInsertId();
} catch (PDOException $exception) {
if ((string) $exception->getCode() === '23000') {
return array('status' => 'username_exists');
}
error_log('User registration failed: '.$exception->getMessage());
return array('status' => 'database_unavailable');
}
if (!auth_start_session() || !session_regenerate_id(TRUE)) {
return array('status' => 'session_unavailable');
}
$_SESSION['auth_user_id'] = $user_id;
auth_reset_cached_user();
return array('status' => 'registered', 'username' => $username);
}
function auth_login($username_value, $password) {
$dummy_hash = '$2y$12$1EmDXXQYPUpbo5wFP6frV.F5Qu6bsg2hw.q9wFG8DxlLRiEQaqcL.';
$username = auth_normalize_username($username_value);
if ($username === FALSE || !auth_password_is_valid($password)) {
password_verify('', $dummy_hash);
return array('status' => 'invalid_credentials');
}
$database = auth_database();
if ($database === FALSE) {
return array('status' => 'database_unavailable');
}
try {
$statement = $database->prepare(
'SELECT id, username, password_hash FROM pgit_users '
.'WHERE username = ? AND is_active = 1 LIMIT 1');
$statement->execute(array($username));
$user = $statement->fetch();
} catch (PDOException $exception) {
error_log('User login lookup failed: '.$exception->getMessage());
return array('status' => 'database_unavailable');
}
$hash = $user === FALSE ? $dummy_hash : $user['password_hash'];
if (!password_verify($password, $hash) || $user === FALSE) {
return array('status' => 'invalid_credentials');
}
if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
try {
$statement = $database->prepare(
'UPDATE pgit_users SET password_hash = ? WHERE id = ?');
$statement->execute(array(password_hash($password, PASSWORD_DEFAULT), $user['id']));
} catch (PDOException $exception) {
error_log('Password rehash failed: '.$exception->getMessage());
}
}
if (!auth_start_session() || !session_regenerate_id(TRUE)) {
return array('status' => 'session_unavailable');
}
$_SESSION['auth_user_id'] = (int) $user['id'];
auth_reset_cached_user();
return array('status' => 'logged_in', 'username' => $user['username']);
}
function auth_logout() {
if (!auth_start_session()) {
return FALSE;
}
unset($_SESSION['auth_user_id'], $_SESSION['home_csrf_token']);
if (!session_regenerate_id(TRUE)) {
return FALSE;
}
auth_reset_cached_user();
return TRUE;
}
function auth_normalize_token_name($value) {
if (!is_string($value)) {
return FALSE;
}
$name = trim($value);
if (!preg_match('~^.{1,80}$~usD', $name) || preg_match('~[\x00-\x1F\x7F]~', $name)) {
return FALSE;
}
return $name;
}
function auth_create_access_token($user_id, $name_value) {
$name = auth_normalize_token_name($name_value);
if ($name === FALSE) {
return array('status' => 'invalid_token_name');
}
$database = auth_database();
if ($database === FALSE) {
return array('status' => 'database_unavailable');
}
try {
$token = 'pgs_'.bin2hex(random_bytes(32));
$statement = $database->prepare(
'INSERT INTO pgit_access_tokens (user_id, name, token_hash) VALUES (?, ?, ?)');
$statement->execute(array($user_id, $name, hash('sha256', $token)));
return array('status' => 'token_created', 'name' => $name, 'token' => $token);
} catch (Exception $exception) {
error_log('Access token creation failed: '.$exception->getMessage());
return array('status' => 'database_unavailable');
}
}
function auth_list_access_tokens($user_id) {
$database = auth_database();
if ($database === FALSE) {
return FALSE;
}
try {
$statement = $database->prepare(
'SELECT id, name, created_at, last_used_at, expires_at '
.'FROM pgit_access_tokens WHERE user_id = ? AND revoked_at IS NULL '
.'ORDER BY created_at DESC, id DESC');
$statement->execute(array($user_id));
return $statement->fetchAll();
} catch (PDOException $exception) {
error_log('Access token listing failed: '.$exception->getMessage());
return FALSE;
}
}
function auth_revoke_access_token($user_id, $token_id) {
if (!is_string($token_id) || !preg_match('~^[1-9][0-9]*$~D', $token_id)) {
return array('status' => 'invalid_token');
}
$database = auth_database();
if ($database === FALSE) {
return array('status' => 'database_unavailable');
}
try {
$statement = $database->prepare(
'UPDATE pgit_access_tokens SET revoked_at = CURRENT_TIMESTAMP '
.'WHERE id = ? AND user_id = ? AND revoked_at IS NULL');
$statement->execute(array($token_id, $user_id));
return array('status' => $statement->rowCount() === 1 ? 'token_revoked' : 'invalid_token');
} catch (PDOException $exception) {
error_log('Access token revocation failed: '.$exception->getMessage());
return array('status' => 'database_unavailable');
}
}
function auth_get_authenticated_user() {
global $auth_cached_user_resolved, $auth_cached_user;
if ($auth_cached_user_resolved) {
return $auth_cached_user;
}
$auth_cached_user_resolved = TRUE;
$user = auth_session_user();
$auth_cached_user = $user === NULL ? NULL : $user['username'];
return $auth_cached_user;
}
function auth_get_access_token_user() {
$user = auth_token_user();
return $user === NULL ? NULL : $user['username'];
}
function auth_reset_cached_user() {
global $auth_cached_user_resolved, $auth_cached_user;
$auth_cached_user_resolved = FALSE;
$auth_cached_user = NULL;
}
+13 -4
View File
@@ -30,6 +30,10 @@ function get_request_header($name) {
return $_SERVER[$key];
}
if (!strcasecmp($name, 'Authorization') && isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
return $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
}
if (!strcasecmp($name, 'Content-Type') && isset($_SERVER['CONTENT_TYPE'])) {
return $_SERVER['CONTENT_TYPE'];
}
@@ -51,11 +55,16 @@ function request_content_type_is($content_type, $expected) {
}
function get_authenticated_user() {
if (isset($_SERVER['REMOTE_USER']) && $_SERVER['REMOTE_USER'] !== '') {
return $_SERVER['REMOTE_USER'];
}
return auth_get_authenticated_user();
}
return NULL;
function get_access_token_user() {
return auth_get_access_token_user();
}
function require_authentication($message) {
header('WWW-Authenticate: Basic realm="PHP Git Server", charset="UTF-8"');
send_error(401, 'Unauthorized', $message);
}
function format_packet_line($payload) {
+1 -1
View File
@@ -18,7 +18,7 @@ function create_http_request($url_path, $repository) {
'content_length' => get_request_header('Content-Length'),
'content_encoding' => get_request_header('Content-Encoding'),
'git_protocol' => get_request_header('Git-Protocol'),
'user' => get_authenticated_user());
'user' => get_access_token_user());
}
function request_has_service($request, $service) {
+1 -1
View File
@@ -20,7 +20,7 @@ function push_require_access($repository, $request) {
}
if ($repository['options']['require_auth'] && $request['user'] === NULL) {
send_error(403, 'Forbidden', 'Authenticated access is required for push.');
require_authentication('A valid username and access token are required for push.');
}
}
+26
View File
@@ -0,0 +1,26 @@
CREATE TABLE pgit_users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
password_hash VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY pgit_users_username_unique (username)
) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE pgit_access_tokens (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(80) NOT NULL,
token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP NULL DEFAULT NULL,
expires_at TIMESTAMP NULL DEFAULT NULL,
revoked_at TIMESTAMP NULL DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY pgit_access_tokens_hash_unique (token_hash),
KEY pgit_access_tokens_user_active (user_id, revoked_at),
CONSTRAINT pgit_access_tokens_user_foreign
FOREIGN KEY (user_id) REFERENCES pgit_users (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+70 -36
View File
@@ -18,6 +18,7 @@ Git 协议不会向服务器发送名为“branch”或“tag”的独立命令
```text
index.php 主入口,加载配置并注册路由
lib/http.php HTTP 状态、响应头和认证用户读取
lib/auth.php MySQL 用户、网页登录会话与 Access Token 验证
lib/repository.php 仓库配置、安全路径和 Dumb HTTP refs
lib/router.php 请求路由
lib/git_service.php Smart HTTP Git 子进程与流式传输
@@ -34,12 +35,13 @@ operations/tag.php refs/tags/* 标签更新规则
- Apache HTTP Server。
- PHP 7.4 或更新版本。
- MySQL 5.7+/MariaDB 10.2+ 与 PHP PDO MySQL 扩展(`pdo_mysql`)。
- Apache `mod_rewrite` 模块。
- 允许项目目录中的 `.htaccess` 使用重写规则。
- Smart HTTP 需要服务器安装 Git,并允许 PHP 使用 `proc_open`
- Web 服务器进程对仓库具有读取权限;启用 push 时还需要写入权限。
项目没有 Composer 依赖,也不需要构建。
项目没有 Composer 依赖,也不需要构建。若系统尚未启用 PDO MySQL,先安装对应 PHP 扩展并重启 Apache/PHP-FPM。
服务器可以不安装 Git。Git 或 `proc_open` 不可用时,应用使用纯 PHP 实现的 Smart HTTP 服务端协议,支持普通 SHA-1 仓库的 clone、fetch、pull、push、delta、分支和标签;PHP 必须启用 zlib 与 hash 扩展。
@@ -61,6 +63,18 @@ cp config.php.sample config.php
不要提交真实的 `config.php`,其中可能包含服务器目录结构和安全策略。
创建数据库、低权限应用用户并导入表结构。下面的密码必须替换为随机强密码:
```sql
CREATE DATABASE php_git_server CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'php_git_server'@'127.0.0.1' IDENTIFIED BY 'replace-with-a-long-random-password';
GRANT SELECT, INSERT, UPDATE ON php_git_server.* TO 'php_git_server'@'127.0.0.1';
```
```sh
mysql -u root -p php_git_server < schema.mysql.sql
```
确认 Apache 已启用重写模块:
```sh
@@ -97,9 +111,17 @@ https://git.example.com/php-git-server/
$url_base = '/php-git-server';
$git_executable = 'git';
$auth = array(
'enabled' => TRUE,
'registration_enabled' => TRUE,
'session_cookie_secure' => TRUE,
'database' => array(
'dsn' => 'mysql:host=127.0.0.1;port=3306;dbname=php_git_server;charset=utf8mb4',
'username' => 'php_git_server',
'password' => 'replace-with-a-long-random-password'));
$managed_repositories = array(
'require_auth' => TRUE,
'session_cookie_secure' => TRUE,
'options' => array(
'read' => TRUE,
'push' => TRUE,
@@ -158,7 +180,6 @@ array('/self.git', '.git')
```php
$managed_repositories = array(
'require_auth' => TRUE,
'session_cookie_secure' => TRUE,
'options' => array(
'read' => TRUE,
'push' => TRUE,
@@ -166,14 +187,14 @@ $managed_repositories = array(
```
- 顶层 `require_auth` 控制谁能从主界面创建仓库,默认是 `TRUE`
- `session_cookie_secure` 控制创建表单会话 Cookie 的 Secure 属性。应用直连 HTTPS 时会自动识别;TLS 在可信反向代理终止时应显式设为 `TRUE`,并确保外部流量只能通过 HTTPS 访问。
- 启用账号认证时,`$auth['session_cookie_secure']` 控制登录与表单共用 Session Cookie 的 Secure 属性。应用直连 HTTPS 时会自动识别;TLS 在可信反向代理终止时应显式设为 `TRUE`,并确保外部流量只能通过 HTTPS 访问。
- `options` 是所有主界面新建仓库共同继承的仓库选项,其含义与 `$repos` 条目相同。
- 设置 `$managed_repositories = array();` 可完全关闭主界面创建功能。
- 仓库名称仅允许字母、数字、点、短横线和下划线,长度最多 64 个字符;`.git` 后缀可省略。
- 新仓库是 bare 仓库,默认分支为 `main`。应用内的创建请求使用锁、暂存目录和原子改名,不会互相覆盖;托管目录不应由其他进程同时写入。
- Git 或 `proc_open` 不可用时,应用以纯 PHP 创建标准 SHA-1 格式的空 bare 仓库;之后可以通过 Dumb HTTP clone,但首次写入仍需在其他具备 Git 的环境中生成仓库内容并同步到服务器。
静态 `$repos` 条目与托管目录中的仓库 URL 冲突时,以静态条目为准。生产环境应让创建页面受到 Web 服务器认证保护,并保持顶层 `require_auth => TRUE`;表单本身还使用会话 CSRF 令牌。
静态 `$repos` 条目与托管目录中的仓库 URL 冲突时,以静态条目为准。生产环境应启用应用账号认证并保持顶层 `require_auth => TRUE`仓库创建要求已登录 Session,所有修改表单还使用会话 CSRF 令牌。
## 5. 仓库选项
@@ -183,7 +204,7 @@ $managed_repositories = array(
| --- | --- | --- |
| `read` | `TRUE` | 允许 clone、fetch、pull 和 Dumb HTTP 对象读取 |
| `push` | `FALSE` | 启用 Smart HTTP receive-pack |
| `require_auth` | `TRUE` | push 前必须存在由 Web 服务器验证并设置的 `REMOTE_USER` |
| `require_auth` | `TRUE` | push 前必须提供有效的应用用户名和 Access Token |
| `branches` | `TRUE` | 允许更新 `refs/heads/*` |
| `tags` | `TRUE` | 允许更新 `refs/tags/*` |
| `other_refs` | `FALSE` | 允许 notes、replace 等其他 ref 命名空间 |
@@ -198,33 +219,26 @@ push 请求会先写入系统临时目录,以便在交给 `git-receive-pack`
## 6. 身份认证
本项目不保存用户、密码或访问令牌。`require_auth => TRUE` 只信任 Web 服务器认证完成后设置的 `REMOTE_USER`;普通客户端提交的用户名不会被当作已认证身份。
### 账号配置
最简单的方式是让 Apache 保护整个仓库 URL
`$auth` 启用后,首页提供注册和登录。用户密码通过 PHP `password_hash()` 保存;应用不会保存明文密码。`registration_enabled => FALSE` 可关闭新用户注册,已有用户仍可登录。生产部署完成首批账号注册后,建议关闭公开注册,或在反向代理/WAF 中为注册和登录请求配置速率限制。
```apache
<LocationMatch "^/php-git-server/project\.git(?:/|$)">
AuthType Basic
AuthName "Private Git"
AuthUserFile /etc/apache2/git.htpasswd
Require valid-user
</LocationMatch>
`session_cookie_secure` 在应用直连 HTTPS 时会自动推断。TLS 在可信反向代理终止时必须显式设为 `TRUE`,并确保外部只能通过 HTTPS 访问。不要再给应用路径配置 Apache `AuthType Basic`,否则 Apache 会在请求到达 PHP 前拦截应用注册、登录及 token 验证。
### 创建和使用 Access Token
登录首页后填写 Token 名称并点击“创建 Token”。明文 token 仅显示一次,格式为 `pgs_` 加 64 个十六进制字符;数据库仅保存 SHA-256 摘要。页面可以查看最后使用时间并随时撤销 token。
Git 通过 HTTP Basic 发送凭据:用户名填写注册用户名,密码填写 access token,不能填写网页登录密码。例如:
```sh
git clone https://git.example.com/php-git-server/project.git
git push origin main
```
启用主界面创建时,还必须让认证覆盖应用首页。例如保护整个应用路径:
Git 收到受保护 push 的 `401` 响应后会提示输入用户名和密码。也可以使用操作系统的 Git Credential Manager 或其他安全凭据助手保存 token;不要把 token 写入远程 URL、shell 历史、仓库配置或脚本。
```apache
<Location "/php-git-server/">
AuthType Basic
AuthName "Git server"
AuthUserFile /etc/apache2/git.htpasswd
Require valid-user
</Location>
```
如果只希望认证创建入口而允许匿名 clone,可在 Web 服务器中按请求方法和路径制定更细的规则,但必须确认首页 `POST` 最终能向 PHP 提供可信的 `REMOTE_USER`
生产环境必须配合 HTTPS。也可以使用反向代理、单点登录或其他认证模块,但需要确认认证结果最终以可信的 `REMOTE_USER` 传给 PHP。
默认允许匿名 clone/fetch/pull`require_auth` 当前保护 push 与主界面仓库创建。Token 验证成功后,用户名会作为 `REMOTE_USER` 传给 Git 子进程和 hooks,现有 hooks 可以继续读取该变量。
如果设置:
@@ -377,6 +391,12 @@ for file in lib/*.php operations/*.php; do
done
```
确认 PDO MySQL 已启用:
```sh
php -m | grep pdo_mysql
```
### 查看远程 refs
```sh
@@ -411,7 +431,7 @@ curl -i https://git.example.com/php-git-server/project.git/not-found
curl -i -X POST https://git.example.com/php-git-server/project.git/HEAD
```
push 未启用或 ref 命名空间被禁止时应返回 `403`
受保护 push 未提供有效 token 时应返回 `401``WWW-Authenticate`push 未启用或 ref 命名空间被禁止时应返回 `403`
## 11. 常见问题
@@ -431,10 +451,23 @@ push 未启用或 ref 命名空间被禁止时应返回 `403`。
依次检查:
1. 仓库是否设置 `'push' => TRUE`
2. `require_auth``TRUE` 时,Web 服务器是否设置了可信 `REMOTE_USER`
3. 分支更新是否启用了 `branches`
4. 标签更新是否启用了 `tags`
5. 目标是否属于其他 ref 命名空间,而 `other_refs` 仍为 `FALSE`
2. 分支更新是否启用了 `branches`
3. 标签更新是否启用了 `tags`
4. 目标是否属于其他 ref 命名空间,而 `other_refs` 仍为 `FALSE`
### push 返回 401 或反复询问密码
依次检查:
1. 密码位置输入的是首页生成的 access token,而不是网页登录密码。
2. 用户名与创建 token 的账号完全一致;用户名区分大小写。
3. token 是否已撤销或被凭据助手缓存为旧值。
4. PHP 是否启用 `pdo_mysql``$auth['database']` 是否能连接数据库。
5. Apache/FastCGI 是否保留 `Authorization` 头;项目 `.htaccess` 已包含对应重写环境变量规则。
### 首页显示认证数据库不可用
检查 PHP/Apache 错误日志、`pdo_mysql` 扩展、MySQL 地址和账号权限,并确认已导入 `schema.mysql.sql`。应用数据库账号需要 `SELECT``INSERT``UPDATE`,不需要运行时建表或删除权限。
### push 返回 500 或远端断开
@@ -465,9 +498,10 @@ GIT_TRACE=1 GIT_CURL_VERBOSE=1 git clone \
- 对所有生产流量使用 HTTPS。
- push 默认保持关闭,只为确实需要写入的仓库启用。
- 主界面创建默认要求可信的 `REMOTE_USER`,托管目录不要放置其他文件。
- 使用 Apache、反向代理或统一身份系统完成真实认证
- 不要把用户自行提供的 HTTP 头直接映射成可信 `REMOTE_USER`
- 主界面创建默认要求已登录应用账号,托管目录不要放置其他文件。
- 公开注册应配合速率限制;不需要公开注册时设置 `registration_enabled => FALSE`
- 数据库账号只授予 `pgit_users``pgit_access_tokens` 所需的最小读写权限,并单独备份
- 定期撤销不再使用的 token;不要记录 `Authorization` 头或 token 明文。
- 仓库路径必须来自静态配置或受控托管目录,不根据 URL 拼接任意文件系统路径。
- 只给 Web 服务器最小必要的文件权限。
-`other_refs` 保持为 `FALSE`,除非确实需要 notes、replace 或自定义 refs。