Skip to content

Commit 5183104

Browse files
committed
feat: unraid single sign on with account app
1 parent d2d0f7c commit 5183104

File tree

16 files changed

+431
-184
lines changed

16 files changed

+431
-184
lines changed

api/src/cli.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ import { cliLogger, internalLogger } from '@app/core/log';
99
import { CliModule } from '@app/unraid-api/cli/cli.module';
1010

1111
try {
12-
const shellToUse = execSync('which bash');
12+
const shellToUse = execSync('which bash').toString().trim();
1313
await CommandFactory.run(CliModule, {
1414
cliName: 'unraid-api',
1515
logger: false,
1616
completion: {
1717
fig: true,
1818
cmd: 'unraid-api',
19-
nativeShell: { executablePath: shellToUse.toString('utf-8') },
19+
nativeShell: { executablePath: shellToUse },
2020
},
2121
});
2222
} catch (error) {

api/src/core/sso/sso-remove.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { existsSync, renameSync, unlinkSync } from 'node:fs';
2+
3+
export const removeSso = () => {
4+
const path = '/usr/local/emhttp/plugins/dynamix/include/.login.php';
5+
const backupPath = path + '.bak';
6+
7+
// Remove the SSO login inject file if it exists
8+
if (existsSync(path)) {
9+
unlinkSync(path);
10+
}
11+
12+
// Move the backup file to the original location
13+
if (existsSync(backupPath)) {
14+
renameSync(backupPath, path);
15+
}
16+
17+
console.log('Restored .login php file');
18+
};

api/src/core/sso/sso-setup.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { existsSync } from 'node:fs';
2+
import { copyFile, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3+
4+
export const setupSso = async () => {
5+
const path = '/usr/local/emhttp/plugins/dynamix/include/.login.php';
6+
7+
// Define the new PHP function to insert
8+
const newFunction = `
9+
function verifyUsernamePasswordAndSSO(string $username, string $password): bool {
10+
if ($username != "root") return false;
11+
12+
$output = exec("/usr/bin/getent shadow $username");
13+
if ($output === false) return false;
14+
$credentials = explode(":", $output);
15+
$valid = password_verify($password, $credentials[1]);
16+
if ($valid) {
17+
return true;
18+
}
19+
// We may have an SSO token, attempt validation
20+
if (strlen($password) > 800) {
21+
$safePassword = escapeshellarg($password);
22+
$response = exec("/usr/local/bin/unraid-api sso validate-token $safePassword", $output, $code);
23+
my_logger("SSO Login Response: $response");
24+
if ($code === 0 && $response && strpos($response, '"valid":true') !== false) {
25+
return true;
26+
}
27+
}
28+
return false;
29+
}`;
30+
31+
const tagToInject = '<?php include "$docroot/plugins/dynamix.my.servers/include/sso-login.php"; ?>';
32+
33+
// Backup the original file if exists
34+
if (existsSync(path + '.bak')) {
35+
await copyFile(path + '.bak', path);
36+
await unlink(path + '.bak');
37+
}
38+
39+
// Read the file content
40+
let fileContent = await readFile(path, 'utf-8');
41+
42+
// Backup the original content
43+
await writeFile(path + '.bak', fileContent);
44+
45+
// Add new function after the opening PHP tag (<?php)
46+
fileContent = fileContent.replace(/<\?php\s*(\r?\n|\r)*/, `<?php\n\n${newFunction}\n`);
47+
48+
// Replace the old function call with the new function name
49+
const functionCallPattern = /!verifyUsernamePassword\(\$username, \$password\)/g;
50+
fileContent = fileContent.replace(
51+
functionCallPattern,
52+
'!verifyUsernamePasswordAndSSO($username, $password)'
53+
);
54+
55+
// Inject the PHP include tag before the closing </body> tag
56+
fileContent = fileContent.replace(/<\/body>/i, `${tagToInject}\n</body>`);
57+
58+
// Write the updated content back to the file
59+
await writeFile(path, fileContent);
60+
61+
console.log('Function replaced successfully.');
62+
};

api/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { WebSocket } from 'ws';
1414

1515
import { logger } from '@app/core/log';
1616
import { setupLogRotation } from '@app/core/logrotate/setup-logrotate';
17+
import { setupSso } from '@app/core/sso/sso-setup';
1718
import { fileExistsSync } from '@app/core/utils/files/file-exists';
1819
import { environment, PORT } from '@app/environment';
1920
import * as envVars from '@app/environment';
@@ -97,6 +98,11 @@ try {
9798

9899
startMiddlewareListeners();
99100

101+
// If the config contains SSO IDs, enable SSO
102+
if (store.getState().config.remote.ssoSubIds) {
103+
await setupSso();
104+
}
105+
100106
// On process exit stop HTTP server
101107
exitHook((signal) => {
102108
console.log('exithook', signal);

api/src/unraid-api/cli/cli.module.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import { Module } from '@nestjs/common';
22

3+
import { ConfigCommand } from '@app/unraid-api/cli/config.command';
34
import { KeyCommand } from '@app/unraid-api/cli/key.command';
45
import { LogService } from '@app/unraid-api/cli/log.service';
6+
import { LogsCommand } from '@app/unraid-api/cli/logs.command';
57
import { ReportCommand } from '@app/unraid-api/cli/report.command';
68
import { RestartCommand } from '@app/unraid-api/cli/restart.command';
9+
import { SSOCommand } from '@app/unraid-api/cli/sso.command';
710
import { StartCommand } from '@app/unraid-api/cli/start.command';
11+
import { StatusCommand } from '@app/unraid-api/cli/status.command';
812
import { StopCommand } from '@app/unraid-api/cli/stop.command';
913
import { SwitchEnvCommand } from '@app/unraid-api/cli/switch-env.command';
1014
import { VersionCommand } from '@app/unraid-api/cli/version.command';
11-
import { StatusCommand } from '@app/unraid-api/cli/status.command';
1215
import { ValidateTokenCommand } from '@app/unraid-api/cli/validate-token.command';
13-
import { LogsCommand } from '@app/unraid-api/cli/logs.command';
14-
import { ConfigCommand } from '@app/unraid-api/cli/config.command';
1516

1617
@Module({
1718
providers: [
@@ -24,9 +25,10 @@ import { ConfigCommand } from '@app/unraid-api/cli/config.command';
2425
SwitchEnvCommand,
2526
VersionCommand,
2627
StatusCommand,
28+
SSOCommand,
2729
ValidateTokenCommand,
2830
LogsCommand,
29-
ConfigCommand
31+
ConfigCommand,
3032
],
3133
})
3234
export class CliModule {}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Injectable } from '@nestjs/common';
2+
3+
import { Command, CommandRunner } from 'nest-commander';
4+
5+
import { LogService } from '@app/unraid-api/cli/log.service';
6+
import { ValidateTokenCommand } from '@app/unraid-api/cli/validate-token.command';
7+
8+
@Injectable()
9+
@Command({
10+
name: 'sso',
11+
description: 'Main Command to Configure / Validate SSO Tokens',
12+
subCommands: [ValidateTokenCommand],
13+
})
14+
export class SSOCommand extends CommandRunner {
15+
constructor(private readonly logger: LogService) {
16+
super();
17+
}
18+
19+
async run(): Promise<void> {
20+
this.logger.info('Please provide a subcommand or use --help for more information');
21+
}
22+
}

api/src/unraid-api/cli/validate-token.command.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import type { JWTPayload } from 'jose';
22
import { createLocalJWKSet, createRemoteJWKSet, decodeJwt, jwtVerify } from 'jose';
3-
import { Command, CommandRunner } from 'nest-commander';
3+
import { CommandRunner, SubCommand } from 'nest-commander';
44

55
import { JWKS_LOCAL_PAYLOAD, JWKS_REMOTE_LINK } from '@app/consts';
66
import { store } from '@app/store';
77
import { loadConfigFile } from '@app/store/modules/config';
88
import { LogService } from '@app/unraid-api/cli/log.service';
99

10-
@Command({
10+
@SubCommand({
1111
name: 'validate-token',
12+
aliases: ['validate', 'v'],
1213
description: 'Returns JSON: { error: string | null, valid: boolean }',
1314
arguments: '<token>',
1415
})
@@ -33,11 +34,13 @@ export class ValidateTokenCommand extends CommandRunner {
3334

3435
async run(passedParams: string[]): Promise<void> {
3536
if (passedParams.length !== 1) {
36-
this.logger.error('Please pass token argument only');
37-
process.exit(1);
37+
this.createErrorAndExit('Please pass token argument only');
3838
}
3939

4040
const token = passedParams[0];
41+
if (typeof token !== 'string' || token.trim() === '') {
42+
this.createErrorAndExit('Invalid token provided');
43+
}
4144

4245
let caughtError: null | unknown = null;
4346
let tokenPayload: null | JWTPayload = null;
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?
2+
3+
/**
4+
* Caveats to get the SSO login button to display
5+
*
6+
* /usr/local/emhttp/auth-request.php must be updated to include the exact URLs of anything that is being loaded.
7+
* Otherwise, the request for the asset will be blocked and redirected to /login.
8+
*
9+
* The modification of these files should be done via the plugin's install script.
10+
*/
11+
require_once("$docroot/plugins/dynamix.my.servers/include/state.php");
12+
require_once("$docroot/plugins/dynamix.my.servers/include/web-components-extractor.php");
13+
14+
$serverState = new ServerState();
15+
16+
$wcExtractor = new WebComponentsExtractor();
17+
echo $wcExtractor->getScriptTagHtml();
18+
?>
19+
20+
<unraid-i18n-host>
21+
<unraid-sso-button server="<?= $serverState->getServerStateJsonForHtmlAttr() ?>"></unraid-sso-button>
22+
</unraid-i18n-host>

plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/include/state.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ class ServerState
5353
"nokeyserver" => 'NO_KEY_SERVER',
5454
"withdrawn" => 'WITHDRAWN',
5555
];
56+
/**
57+
* SSO Sub IDs from the my servers config file.
58+
*/
59+
private $ssoSubIds = '';
5660
private $osVersion;
5761
private $osVersionBranch;
5862
private $rebootDetails;
@@ -193,6 +197,7 @@ private function getMyServersCfgValues() {
193197
$this->registered = !empty($this->myServersFlashCfg['remote']['apikey']) && $this->connectPluginInstalled;
194198
$this->registeredTime = $this->myServersFlashCfg['remote']['regWizTime'] ?? '';
195199
$this->username = $this->myServersFlashCfg['remote']['username'] ?? '';
200+
$this->ssoSubIds = $this->myServersFlashCfg['remote']['ssoSubIds'] ?? '';
196201
}
197202

198203
private function getConnectKnownOrigins() {
@@ -321,6 +326,7 @@ public function getServerState()
321326
"uptime" => 1000 * (time() - round(strtok(exec("cat /proc/uptime"), ' '))),
322327
"username" => $this->username,
323328
"wanFQDN" => @$this->nginxCfg['NGINX_WANFQDN'] ?? '',
329+
"ssoSubIds" => $this->ssoSubIds
324330
];
325331

326332
if ($this->combinedKnownOrigins) {

0 commit comments

Comments
 (0)