Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,8 @@ DB_PASSWORD=secret
# Log when a page takes more this this many seconds to load.
#SLOW_PAGE_TIME=10

# How long authentication tokens should last before expiring (in seconds).
# Default is six months.
# 0 here means that tokens do not expire.
# The maximum allowable token duration (in seconds). Default is six months.
# 0 here means that there's no limit on token validity.
#TOKEN_DURATION=15811200

# Whitelist of projects that are allowed to have unlimited builds.
Expand Down
1 change: 1 addition & 0 deletions app/Http/Controllers/AuthTokenController.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ public function createToken(Request $request): JsonResponse
$projectid,
$request->input('scope'),
$request->input('description'),
$request->date('expiration'),
);
} catch (InvalidArgumentException $e) {
return response()->json(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST);
Expand Down
1 change: 1 addition & 0 deletions app/Http/Controllers/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ public function userPageContent(): JsonResponse
$response['user_name'] = $user->firstname;
$response['user_is_admin'] = $user->admin;
$response['show_monitor'] = config('queue.default') === 'database';
$response['max_token_expiration'] = AuthTokenUtil::getMaximumTokenExpiration()->toIso8601String();

if ((bool) config('cdash.user_create_projects')) {
$response['user_can_create_projects'] = 1;
Expand Down
39 changes: 27 additions & 12 deletions app/Utils/AuthTokenUtil.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,30 +28,30 @@ class AuthTokenUtil
*
* @throws InvalidArgumentException
*/
public static function generateToken(int $user_id, int $project_id, string $scope, string $description): array
public static function generateToken(int $user_id, int $project_id, string $scope, string $description, ?Carbon $expiration = null): array
{
$params = [];

// 86 characters generates more than 512 bits of entropy (and is thus limited by the entropy of the hash)
$token = Str::password(86, true, true, false);
$params['hash'] = hash('sha512', $token);

$params['userid'] = $user_id;

$duration = Config::get('cdash.token_duration');
$now = time();
$params['created'] = gmdate(FMT_DATETIME, $now);
$now = Carbon::now();
$params['created'] = $now->toIso8601String();

if (!is_numeric($duration) || (int) $duration < 0) {
Log::error("Invalid token_duration configuration {$duration}");
throw new InvalidArgumentException('Invalid token_duration configuration');
// The default expiration date is 1 year in the future.
if ($expiration === null) {
$expiration = $now->addYear();
}

if ((int) $duration === 0) {
// this token "never" expires
$params['expires'] = '9999-01-01 00:00:00';
} else {
$params['expires'] = gmdate(FMT_DATETIME, $now + $duration);
if ($expiration->isNowOrPast()) {
throw new InvalidArgumentException('Token expiration cannot be in the past.');
}

$params['expires'] = $expiration->min(self::getMaximumTokenExpiration())->toIso8601String();

$params['description'] = $description;

if (!self::validScope($scope)) {
Expand Down Expand Up @@ -283,4 +283,19 @@ public static function getBearerToken(): ?string
{
return request()->bearerToken();
}

public static function getMaximumTokenExpiration(): Carbon
{
$maxDuration = Config::get('cdash.token_duration');
if (!is_numeric($maxDuration) || (int) $maxDuration < 0) {
Log::error("Invalid token_duration configuration {$maxDuration}");
throw new InvalidArgumentException('Invalid token_duration configuration');
}

// A maximum duration of 0 is equivalent to no limit.
if ((int) $maxDuration === 0) {
return (new Carbon())->endOfMillennium();
}
return Carbon::now()->addSeconds((int) $maxDuration);
}
}
2 changes: 2 additions & 0 deletions app/cdash/tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,8 @@ add_feature_test_in_transaction(/Feature/Jobs/PruneAuthTokensTest)

add_feature_test_in_transaction(/Feature/UserCommand)

add_feature_test_in_transaction(/Feature/AuthTokenTest)

add_feature_test_in_transaction(/Feature/RemoteWorkers)
set_property(TEST /Feature/RemoteWorkers APPEND PROPERTY
DISABLED "$<NOT:$<STREQUAL:${CDASH_STORAGE_TYPE},local>>"
Expand Down
20 changes: 7 additions & 13 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -6060,26 +6060,14 @@ parameters:
count: 1
path: app/Utils/AuthTokenUtil.php

-
rawMessage: Implicit array creation is not allowed - variable $params does not exist.
identifier: variable.implicitArray
count: 1
path: app/Utils/AuthTokenUtil.php

-
rawMessage: 'Method App\Utils\AuthTokenUtil::getUserIdFromRequest() should return int|null but returns mixed.'
identifier: return.type
count: 1
path: app/Utils/AuthTokenUtil.php

-
rawMessage: 'Parameter #2 $timestamp of function gmdate expects int|null, float|int given.'
identifier: argument.type
count: 1
path: app/Utils/AuthTokenUtil.php

-
rawMessage: 'Part $duration (mixed) of encapsed string cannot be cast to string.'
rawMessage: 'Part $maxDuration (mixed) of encapsed string cannot be cast to string.'
identifier: encapsedStringPart.nonString
count: 1
path: app/Utils/AuthTokenUtil.php
Expand Down Expand Up @@ -26817,6 +26805,12 @@ parameters:
count: 1
path: tests/Browser/Pages/UsersPageTest.php

-
rawMessage: 'Parameter #1 $time of static method Carbon\Carbon::parse() expects Carbon\Month|Carbon\WeekDay|DateTimeInterface|float|int|string|null, mixed given.'
identifier: argument.type
count: 1
path: tests/Feature/AuthTokenTest.php

-
rawMessage: Access to an undefined property CDash\Model\Build::$Endime.
identifier: property.notFound
Expand Down
36 changes: 28 additions & 8 deletions resources/js/vue/components/UserHomepage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,7 @@
Submit Only{{ authtoken.projectname && authtoken.projectname.length > 0 ? ' (' + authtoken.projectname + ')' : '' }}
</td>
<td align="center">
{{ authtoken.expires }}
{{ formatTokenExpiration(authtoken.expires) }}
</td>
<td align="center">
<span
Expand Down Expand Up @@ -523,7 +523,7 @@
>New Token:</label>
<input
id="tokenDescription"
v-model="cdash.tokendescription"
v-model="tokendescription"
type="text"
name="tokenDescription"
class="form-control"
Expand Down Expand Up @@ -555,11 +555,18 @@
</option>
</select>
</td>
<td />
<td>
<input
v-model="tokenexpiration"
type="date"
:min="minTokenExpiration"
:max="maxTokenExpiration"
>
</td>
<td align="center">
<button
class="btn btn-default"
:disabled="!cdash.tokendescription"
:disabled="!tokendescription"
@click="generateToken()"
>
Generate Token
Expand Down Expand Up @@ -697,6 +704,7 @@
<script>
import ApiLoader from './shared/ApiLoader';
import LoadingIndicator from './shared/LoadingIndicator.vue';
import {DateTime} from 'luxon';
import {FontAwesomeIcon} from '@fortawesome/vue-fontawesome';
import {
faBell,
Expand All @@ -718,6 +726,8 @@ export default {
loading: true,
errored: false,
tokenscope: 'full_access',
tokendescription: null,
tokenexpiration: null,
};
},

Expand All @@ -732,6 +742,14 @@ export default {
faUserPen,
};
},

minTokenExpiration() {
return DateTime.now().plus({ days: 1 }).toISODate();
},

maxTokenExpiration() {
return DateTime.fromISO(this.cdash.max_token_expiration).toISODate();
},
},

mounted () {
Expand All @@ -741,8 +759,9 @@ export default {
methods: {
generateToken: function () {
const parameters = {
description: this.cdash.tokendescription,
description: this.tokendescription,
scope: this.tokenscope === 'full_access' ? 'full_access' : 'submit_only',
expiration: DateTime.fromISO(this.tokenexpiration).toISO(),
projectid: this.tokenscope === 'full_access' || this.tokenscope === 'submit_only' ? -1 : this.tokenscope,
};
this.$axios.post('/api/authtokens/create', parameters)
Expand All @@ -758,9 +777,6 @@ export default {
}
});

// A terrible hack to format the date the same way the DB returns them on initial page load
authtoken.expires = authtoken.expires.replace('T', ' ');

this.cdash.authtokens.push(authtoken);
})
.catch(error => {
Expand Down Expand Up @@ -804,6 +820,10 @@ export default {
this.error = error.toString();
}
},

formatTokenExpiration(timestampString) {
return DateTime.fromISO(timestampString, {setZone: true}).toFormat('yyyy-MM-dd');
},
},
};
</script>
Expand Down
63 changes: 63 additions & 0 deletions tests/Feature/AuthTokenTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

namespace Tests\Feature;

use App\Models\AuthToken;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
use Tests\Traits\CreatesUsers;

class AuthTokenTest extends TestCase
{
use CreatesUsers;
use DatabaseTransactions;

/**
* @return array<array<mixed>>
*/
public static function setTokenExpirationCases(): array
{
$oneYear = Carbon::now()->addYear();
$oneMonth = Carbon::now()->addMonth();
$oneMonthAgo = Carbon::now()->subMonth();

return [
// Defaults to 1 year if no expiration provided
[null, $oneYear, 0, true],
// Basic case with no limit
[$oneMonth, $oneMonth, 0, true],
// Sets to limit if expiration beyond limit
[$oneYear, $oneMonth, (int) Carbon::now()->diffInUTCSeconds($oneMonth), true],
// Test expiration in the past
[$oneMonthAgo, Carbon::now(), 0, false],
];
}

#[DataProvider('setTokenExpirationCases')]
public function testSetTokenExpiration(
?Carbon $expirationParam,
Carbon $expectedExpiration,
int $tokenDuration,
bool $shouldPass,
): void {
config(['cdash.token_duration' => $tokenDuration]);
$admin = $this->makeAdminUser();

$response = $this->actingAs($admin)->post('/api/authtokens/create', [
'description' => Str::uuid()->toString(),
'scope' => AuthToken::SCOPE_FULL_ACCESS,
'expiration' => $expirationParam,
]);

if ($shouldPass) {
$response->assertOk();
$actualExpiration = Carbon::parse($response->json('token.expires'));
self::assertEqualsWithDelta($expectedExpiration->timestamp, $actualExpiration->timestamp, 10);
} else {
$response->assertBadRequest();
}
}
}