Forráskód Böngészése

Allow SSH keys authentication for SFTP(Go)

Miraty 2 éve
szülő
commit
067e1ccf42

+ 1 - 1
README.md

@@ -24,7 +24,7 @@ I plan to create and maintain a public stable instance of ServNest, but I haven'
 ### Name server (`ns`)
 
 * Host a zone on the server
-* Zone file edition through `<textarea>`
+* Plain zone file edition
 * Dedicated forms to set/unset `A`, `AAAA`, `NS`, `TXT`, `CAA`, `SRV`, `MX`, `SRV`, `SSHFP`, `TLSA`, `CNAME`, `DNAME` and `LOC` records
 * Display records or the full zone file
 

+ 8 - 0
css/form.css

@@ -41,6 +41,14 @@ input[type=password] {
 	width: 7ch;
 }
 
+#public-key {
+	width: 70ch;
+}
+
+#key {
+	width: 65ch;
+}
+
 :disabled {
 	cursor: not-allowed;
 }

+ 1 - 1
css/main.css

@@ -36,7 +36,7 @@ h3 {
 	font-size: 1.1rem;
 }
 
-main > *:not(pre, form), form > *:not(textarea), footer {
+main > *:not(pre, form), footer {
 	max-width: 40rem;
 	margin-left: auto;
 	margin-right: auto;

+ 11 - 0
db/migrations/005-create-ssh-keys-table.sql

@@ -0,0 +1,11 @@
+BEGIN TRANSACTION;
+
+CREATE TABLE IF NOT EXISTS "ssh-keys" (
+	"key"       TEXT    NOT NULL,
+	"username"  TEXT    NOT NULL,
+	"directory" TEXT    NOT NULL,
+	UNIQUE("key", "username", "directory"),
+	FOREIGN KEY("username") REFERENCES "users"("id")
+);
+
+COMMIT;

+ 7 - 0
db/schema.sql

@@ -53,4 +53,11 @@ CREATE TABLE IF NOT EXISTS "sites" (
 	PRIMARY KEY("address", "type"),
 	FOREIGN KEY("username") REFERENCES "users"("id")
 );
+CREATE TABLE IF NOT EXISTS "ssh-keys" (
+	"key"       TEXT    NOT NULL,
+	"username"  TEXT    NOT NULL,
+	"directory" TEXT    NOT NULL,
+	UNIQUE("key", "username", "directory"),
+	FOREIGN KEY("username") REFERENCES "users"("id")
+);
 COMMIT;

+ 3 - 0
fn/ht.php

@@ -1,6 +1,7 @@
 <?php
 
 const SUBPATH_REGEX = '^[a-z0-9-]{4,63}$';
+const ED25519_PUBKEY_REGEX = '^[a-zA-Z0-9/+]{68}$';
 
 function htSetupUserFs($id) {
 	// Setup SFTP directory
@@ -37,6 +38,8 @@ function formatDomain($domain) {
 }
 
 function listFsDirs($username) {
+	if ($username === '')
+		return [];
 	$absoluteDirs = glob(CONF['ht']['ht_path'] . '/fs/' . $username . '/*/', GLOB_ONLYDIR);
 	$dirs = [];
 	foreach ($absoluteDirs as $absoluteDir)

+ 61 - 17
locales/fr/C/LC_MESSAGES/messages.po

@@ -1,7 +1,7 @@
 msgid ""
 msgstr ""
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2023-06-04 23:57+0200\n"
+"POT-Creation-Date: 2023-06-15 01:33+0200\n"
 "Language: fr\n"
 "Content-Type: text/plain; charset=UTF-8\n"
 
@@ -274,18 +274,26 @@ msgstr "Supprimer un accès"
 msgid "Delete an existing HTTP access from a subdirectory of the SFTP space"
 msgstr "Retirer un accès HTTP existant d'un sous-dossier de l'espace SFTP"
 
-#: router.php:152 view.php:39
+#: pages.php:197
+msgid "Manage SSH keys"
+msgstr "Gérer les clés SSH"
+
+#: pages.php:198
+msgid "Choose what SSH key can edit what directory"
+msgstr "Choisir quelle clé SSH peut modifier quel dossier"
+
+#: router.php:68
+msgid "This account doesn't exist anymore. Log out to end this ghost session."
+msgstr "Ce compte n'existe plus. Déconnectez-vous pour terminer cette session fantôme."
+
+#: router.php:106 view.php:39
 msgid "This service is currently under maintenance. No action can be taken on it until an administrator finishes repairing it."
 msgstr "Ce service est en cours de maintenance. Aucune action ne peut être effectuée avant qu'ane administrataire termine de le réparer."
 
-#: router.php:162
+#: router.php:115
 msgid "You need to be logged in to do this."
 msgstr "Vous devez être connecté·e à un compte pour faire cela."
 
-#: router.php:164
-msgid "This account doesn't exist anymore. Log out to end this ghost session."
-msgstr "Ce compte n'existe plus. Déconnectez-vous pour terminer cette session fantôme."
-
 #: view.php:19
 msgid "You are using a testing account. It may be deleted anytime."
 msgstr "Vous utilisez un compte de test. Il risque d'être supprimé n'importe quand."
@@ -308,11 +316,16 @@ msgstr "Ce formulaire ne sera pas accepté car il faut %sse connecter%s d'abord.
 msgid "%sSource code%s available under %s."
 msgstr "%sCode source%s disponible sous %s."
 
-#: fn/auth.php:110
+#: fn/auth.php:95
+#, php-format
+msgid "Your account can't be deleted because the %s service is currently unavailable."
+msgstr "Votre compte ne peut pas être supprimé car le service %s est actuellement indisponible."
+
+#: fn/auth.php:143
 msgid "Account rate limit reached, try again later."
 msgstr "Limite de taux pour ce compte atteinte, réessayez plus tard."
 
-#: fn/auth.php:135
+#: fn/auth.php:168
 msgid "Global rate limit reached, try again later."
 msgstr "Limite de taux globale atteinte, réessayez plus tard."
 
@@ -328,15 +341,15 @@ msgstr "<strong>Erreur de l'utilisataire</strong>&nbsp;: "
 msgid "<strong>Server error</strong>: "
 msgstr "<strong>Erreur du serveur</strong>&nbsp;: "
 
-#: fn/common.php:129
+#: fn/common.php:132
 msgid "Wrong proof."
 msgstr "Preuve incorrecte."
 
-#: fn/dns.php:63
+#: fn/dns.php:64
 msgid "IP address malformed."
 msgstr "Adresse IP malformée."
 
-#: fn/dns.php:68 fn/ht.php:30
+#: fn/dns.php:69 fn/ht.php:31
 msgid "Domain malformed."
 msgstr "Domaine malformé."
 
@@ -396,11 +409,6 @@ msgid "Account deletion must be confirmed."
 msgstr "La suppression du compte doit être confirmée."
 
 #: pg-act/auth/unregister.php:13
-#, php-format
-msgid "Your account can't be deleted because the %s service is currently unavailable."
-msgstr "Votre compte ne peut pas être supprimé car le service %s est actuellement indisponible."
-
-#: pg-act/auth/unregister.php:42
 msgid "Account deleted."
 msgstr "Compte supprimé."
 
@@ -453,6 +461,18 @@ msgstr "Ce chemin est déjà pris sur ce service. Utilisez-en un autre."
 msgid "Access removed."
 msgstr "Accès retiré."
 
+#: pg-act/ht/keys.php:13
+msgid "Path is not valid."
+msgstr "Le chemin n'est pas valide."
+
+#: pg-act/ht/keys.php:15
+msgid "Ed25519 public key seems wrongly formatted."
+msgstr "La clé public Ed25519 semble mal formattée."
+
+#: pg-act/ht/keys.php:39
+msgid "SSH keys updated."
+msgstr "Clés SSH mises à jour."
+
 #: pg-act/ns/caa.php:25 pg-act/ns/cname.php:16 pg-act/ns/dname.php:16
 #: pg-act/ns/ip.php:16 pg-act/ns/loc.php:72 pg-act/ns/mx.php:20
 #: pg-act/ns/ns.php:16 pg-act/ns/srv.php:28 pg-act/ns/sshfp.php:25
@@ -813,6 +833,30 @@ msgstr "Approuvé"
 msgid "Stable Let's Encrypt certificates"
 msgstr "Vrai certificat Let's Encrypt"
 
+#: pg-view/ht/keys.php:2
+msgid "In addition to your password, you can also access your SFTP space using Ed25519 SSH keys. A key can be granted modification rights to the full space (<code>/</code>) or to any arbitrary subdirectory. A key is always  allowed to list any directory content."
+msgstr "En plus de la clé de passe, c'est également possible d'accéder à l'espace SFTP en utilisant des clés SSH Ed25519. Une clé peut être autorisée à modifier dans tout l'espace (<code>/</code>) ou dans un quelconque sous-dossier spécifique. Une clé est toujours autorisée à lister le contenu de n'importe quel dossier."
+
+#: pg-view/ht/keys.php:17
+msgid "Add new SSH key access"
+msgstr "Ajouter un nouvel accès par clé SSH"
+
+#: pg-view/ht/keys.php:17
+msgid "SSH key access"
+msgstr "Accès par clé SSH"
+
+#: pg-view/ht/keys.php:19
+msgid "Public key"
+msgstr "Clé publique"
+
+#: pg-view/ht/keys.php:23
+msgid "Allowed directory"
+msgstr "Dossier autorisé"
+
+#: pg-view/ht/keys.php:30
+msgid "Update"
+msgstr "Mettre à jour"
+
 #: pg-view/ns/caa.php:3
 msgid "Flag"
 msgstr "Flag"

+ 60 - 16
locales/messages.pot

@@ -8,7 +8,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE VERSION\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2023-06-04 23:57+0200\n"
+"POT-Creation-Date: 2023-06-15 01:33+0200\n"
 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -286,18 +286,26 @@ msgstr ""
 msgid "Delete an existing HTTP access from a subdirectory of the SFTP space"
 msgstr ""
 
-#: router.php:152 view.php:39
-msgid "This service is currently under maintenance. No action can be taken on it until an administrator finishes repairing it."
+#: pages.php:197
+msgid "Manage SSH keys"
 msgstr ""
 
-#: router.php:162
-msgid "You need to be logged in to do this."
+#: pages.php:198
+msgid "Choose what SSH key can edit what directory"
 msgstr ""
 
-#: router.php:164
+#: router.php:68
 msgid "This account doesn't exist anymore. Log out to end this ghost session."
 msgstr ""
 
+#: router.php:106 view.php:39
+msgid "This service is currently under maintenance. No action can be taken on it until an administrator finishes repairing it."
+msgstr ""
+
+#: router.php:115
+msgid "You need to be logged in to do this."
+msgstr ""
+
 #: view.php:19
 msgid "You are using a testing account. It may be deleted anytime."
 msgstr ""
@@ -320,11 +328,16 @@ msgstr ""
 msgid "%sSource code%s available under %s."
 msgstr ""
 
-#: fn/auth.php:110
+#: fn/auth.php:95
+#, php-format
+msgid "Your account can't be deleted because the %s service is currently unavailable."
+msgstr ""
+
+#: fn/auth.php:143
 msgid "Account rate limit reached, try again later."
 msgstr ""
 
-#: fn/auth.php:135
+#: fn/auth.php:168
 msgid "Global rate limit reached, try again later."
 msgstr ""
 
@@ -340,15 +353,15 @@ msgstr ""
 msgid "<strong>Server error</strong>: "
 msgstr ""
 
-#: fn/common.php:129
+#: fn/common.php:132
 msgid "Wrong proof."
 msgstr ""
 
-#: fn/dns.php:63
+#: fn/dns.php:64
 msgid "IP address malformed."
 msgstr ""
 
-#: fn/dns.php:68 fn/ht.php:30
+#: fn/dns.php:69 fn/ht.php:31
 msgid "Domain malformed."
 msgstr ""
 
@@ -408,11 +421,6 @@ msgid "Account deletion must be confirmed."
 msgstr ""
 
 #: pg-act/auth/unregister.php:13
-#, php-format
-msgid "Your account can't be deleted because the %s service is currently unavailable."
-msgstr ""
-
-#: pg-act/auth/unregister.php:42
 msgid "Account deleted."
 msgstr ""
 
@@ -465,6 +473,18 @@ msgstr ""
 msgid "Access removed."
 msgstr ""
 
+#: pg-act/ht/keys.php:13
+msgid "Path is not valid."
+msgstr ""
+
+#: pg-act/ht/keys.php:15
+msgid "Ed25519 public key seems wrongly formatted."
+msgstr ""
+
+#: pg-act/ht/keys.php:39
+msgid "SSH keys updated."
+msgstr ""
+
 #: pg-act/ns/caa.php:25 pg-act/ns/cname.php:16 pg-act/ns/dname.php:16
 #: pg-act/ns/ip.php:16 pg-act/ns/loc.php:72 pg-act/ns/mx.php:20
 #: pg-act/ns/ns.php:16 pg-act/ns/srv.php:28 pg-act/ns/sshfp.php:25
@@ -825,6 +845,30 @@ msgstr ""
 msgid "Stable Let's Encrypt certificates"
 msgstr ""
 
+#: pg-view/ht/keys.php:2
+msgid "In addition to your password, you can also access your SFTP space using Ed25519 SSH keys. A key can be granted modification rights to the full space (<code>/</code>) or to any arbitrary subdirectory. A key is always  allowed to list any directory content."
+msgstr ""
+
+#: pg-view/ht/keys.php:17
+msgid "Add new SSH key access"
+msgstr ""
+
+#: pg-view/ht/keys.php:17
+msgid "SSH key access"
+msgstr ""
+
+#: pg-view/ht/keys.php:19
+msgid "Public key"
+msgstr ""
+
+#: pg-view/ht/keys.php:23
+msgid "Allowed directory"
+msgstr ""
+
+#: pg-view/ht/keys.php:30
+msgid "Update"
+msgstr ""
+
 #: pg-view/ns/caa.php:3
 msgid "Flag"
 msgstr ""

+ 5 - 0
pages.php

@@ -193,5 +193,10 @@ define('PAGES', [
 			'title' => _('Delete access'),
 			'description' => _('Delete an existing HTTP access from a subdirectory of the SFTP space'),
 		],
+		'keys' => [
+			'title' => _('Manage SSH keys'),
+			'description' => _('Choose what SSH key can edit what directory'),
+			'tokens_account_cost' => 300,
+		],
 	],
 ]);

+ 0 - 1
pg-act/auth/login.php

@@ -26,4 +26,3 @@ $_SESSION['type'] = query('select', 'users', ['id' => $id], 'type')[0];
 setupDisplayUsername($_POST['username']);
 
 redir();
-

+ 39 - 0
pg-act/ht/keys.php

@@ -0,0 +1,39 @@
+<?php
+
+$el_nb = count($_POST['keys']);
+if ($el_nb < 1 OR $el_nb > 8)
+	output(403, 'Wrong elements number.');
+
+foreach ($_POST['keys'] as $i => $key) {
+	if (($key['public-key'] ?? '') === '') {
+		unset($_POST['keys'][$i]);
+		continue;
+	}
+	if (preg_match('#^/[/\p{L}\{M}\p{N}\p{P}\p{S}\p{Zs}]{1,254}$#Du', $key['dir'] ?? '') !== 1)
+		output(403, _('Path is not valid.'));
+	if (preg_match('#' . ED25519_PUBKEY_REGEX . '#D', $key['public-key']) !== 1)
+		output(403, _('Ed25519 public key seems wrongly formatted.'));
+}
+$keys = array_values($_POST['keys']);
+
+rateLimit();
+
+try {
+	DB->beginTransaction();
+
+	query('delete', 'ssh-keys', ['username' => $_SESSION['id']]);
+
+	foreach ($keys as $key)
+		insert('ssh-keys', [
+			'key' => $key['public-key'],
+			'username' => $_SESSION['id'],
+			'directory' => $key['dir'],
+		]);
+
+	DB->commit();
+} catch (Exception $e) {
+	DB->rollback();
+	output(500, 'Database error.', [$e->getMessage()]);
+}
+
+output(200, _('SSH keys updated.'));

+ 31 - 0
pg-view/ht/keys.php

@@ -0,0 +1,31 @@
+<p>
+	<?= _('In addition to your password, you can also access your SFTP space using Ed25519 SSH keys. A key can be granted modification rights to the full space (<code>/</code>) or to any arbitrary subdirectory. A key is always  allowed to list any directory content.') ?>
+</p>
+
+<form method="post">
+	<datalist id="dirs">
+		<option value="/"></option>
+<?php
+foreach (listFsDirs($_SESSION['id'] ?? '') as $dir)
+	echo '		<option value="/' . $dir . '"></option>' . LF;
+?>
+	</datalist>
+<?php
+foreach (array_slice(array_merge(query('select', 'ssh-keys', ['username' => $_SESSION['id'] ?? '']), [['key' => '', 'username' => '', 'directory' => '/']]), 0, 8) as $i => $ssh_key) {
+?>
+	<fieldset>
+		<legend><?= ($ssh_key['key'] === '') ? _('Add new SSH key access') : _('SSH key access') ?></legend>
+		<div>
+			<label for="public-key"><?= _('Public key') ?></label><br>
+			<code>ssh-ed15519 <input pattern="<?= ED25519_PUBKEY_REGEX ?>" placeholder="AAAAC3NzaC1lZDI1NTE5AAAAI<?= substr(base64_encode(random_bytes(32)), 0, 43) ?>" id="public-key" name="keys[<?= $i ?>][public-key]" value="<?= $ssh_key['key'] ?>" type="text"></code>
+		</div>
+		<div>
+			<label for="dir"><?= _('Allowed directory') ?></label><br>
+			<input list="dirs" placeholder="/" value="<?= htmlspecialchars($ssh_key['directory']) ?>" id="dir" name="keys[<?= $i ?>][dir]" type="text">
+		</div>
+	</fieldset>
+<?php
+}
+?>
+	<input type="submit" value="<?= _('Update') ?>">
+</form>

+ 29 - 22
sftpgo-auth.php

@@ -1,4 +1,4 @@
-<?php
+<?php // ServNest authenticator for SFTPGo https://github.com/drakkan/sftpgo/blob/main/docs/external-auth.md
 
 const DEBUG = false;
 !DEBUG or ob_start();
@@ -21,27 +21,34 @@ $username = hashUsername($auth_data['username']);
 if (usernameExists($username) !== true)
 	deny('This username doesn\'t exist.');
 
-if (!in_array('ht', explode(',', query('select', 'users', ['username' => $username], 'services')[0]), true))
-	deny('Service not enabled for this user.');
+$account = query('select', 'users', ['username' => $username])[0];
 
-$id = query('select', 'users', ['username' => $username], 'id')[0];
-
-if (checkPassword($id, $auth_data['password']) !== true)
-	deny('Wrong password.');
-
-echo '
-{
-	"status": 1,
-	"username": ' . json_encode($auth_data['username']) . ',
-	"home_dir": "' . CONF['ht']['ht_path'] . '/fs/' . $id . '",
-	"quota_size": ' . ((query('select', 'users', ['id' => $id], 'type')[0] === 'approved') ? CONF['ht']['user_quota_approved'] : CONF['ht']['user_quota_testing']) . ',
-	"permissions": {
-		"/": [
-			"*"
-		]
-	}
-}
-';
+if (!in_array('ht', explode(',', $account['services']), true))
+	deny('Service not enabled for this user.');
 
-!DEBUG or file_put_contents(ROOT_PATH . '/db/debug.txt', ob_get_contents());
+const SFTPGO_DENY_PERMS = ['/' => ['list']];
+const SFTPGO_ALLOW_PERMS = ['list', 'download', 'upload', 'overwrite', 'delete_files', 'delete_dirs', 'rename_files', 'rename_dirs', 'create_dirs', 'chtimes'];
+if ($auth_data['password'] !== '') {
+	if (checkPassword($account['id'], $auth_data['password']) !== true)
+		deny('Wrong password.');
+	$permissions['/'] = SFTPGO_ALLOW_PERMS;
+} else if ($auth_data['public_key'] !== '') {
+	$permissions = SFTPGO_DENY_PERMS;
+	foreach (query('select', 'ssh-keys', ['username' => $account['id']]) as $key)
+		if (hash_equals('ssh-ed25519 ' . $key['key'] . LF, $auth_data['public_key']))
+			$permissions[$key['directory']] = SFTPGO_ALLOW_PERMS;
+	if ($permissions === SFTPGO_DENY_PERMS)
+		deny('No matching SSH key allowed.');
+} else
+	deny('Unknown authentication method.');
+
+echo json_encode([
+	'status' => 1,
+	'username' => $auth_data['username'],
+	'home_dir' => CONF['ht']['ht_path'] . '/fs/' . $account['id'],
+	'quota_size' => ($account['type'] === 'approved') ? CONF['ht']['user_quota_approved'] : CONF['ht']['user_quota_testing'],
+	'permissions' => $permissions,
+], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
+
+!DEBUG or file_put_contents(ROOT_PATH . '/db/debug.txt', ob_get_contents() . 'accepted');
 http_response_code(200);