add login and register
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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。
|
||||
|
||||
Reference in New Issue
Block a user