Skip to content
Merged
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
19 changes: 18 additions & 1 deletion app/V1Module/presenters/LoginPresenter.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use App\Security\Roles;
use App\Security\TokenScope;
use Nette\Security\AuthenticationException;
use Nette\Http\IResponse;

/**
* Endpoints used to log a user in
Expand Down Expand Up @@ -201,6 +202,14 @@ public function actionRefresh()
$token = $this->getAccessToken();

$user = $this->getCurrentUser();
if (!$user->isAllowed()) {
throw new ForbiddenRequestException(
"Forbidden Request - User account was disabled",
IResponse::S403_Forbidden,
FrontendErrorMappings::E403_002__USER_NOT_ALLOWED
);
}

$user->updateLastAuthenticationAt();
$this->users->flush();

Expand Down Expand Up @@ -247,6 +256,14 @@ public function actionIssueRestrictedToken()
$this->validateEffectiveRole($effectiveRole);

$user = $this->getCurrentUser();
if (!$user->isAllowed()) {
throw new ForbiddenRequestException(
"Forbidden Request - User account was disabled",
IResponse::S403_Forbidden,
FrontendErrorMappings::E403_002__USER_NOT_ALLOWED
);
}

$user->updateLastAuthenticationAt();
$this->users->flush();

Expand All @@ -265,7 +282,7 @@ private function validateScopeRoles(?array $scopes, $expiration)
{
$forbiddenScopes = [
TokenScope::CHANGE_PASSWORD =>
"Password change tokens can only be issued through the password reset endpoint",
"Password change tokens can only be issued through the password reset endpoint",
TokenScope::EMAIL_VERIFICATION => "E-mail verification tokens must be received via e-mail",
];

Expand Down
93 changes: 89 additions & 4 deletions app/V1Module/presenters/UsersPresenter.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@
use App\Exceptions\InvalidArgumentException;
use App\Exceptions\NotFoundException;
use App\Exceptions\WrongCredentialsException;
use App\Model\Entity\ExternalLogin;
use App\Model\Entity\Group;
use App\Model\Entity\Login;
use App\Model\Entity\SecurityEvent;
use App\Model\Entity\User;
use App\Model\Entity\UserUiData;
use App\Model\Repository\ExternalLogins;
use App\Model\Repository\Logins;
use App\Model\Repository\SecurityEvents;
use App\Exceptions\BadRequestException;
Expand All @@ -36,6 +38,12 @@ class UsersPresenter extends BasePresenter
*/
public $logins;

/**
* @var ExternalLogins
* @inject
*/
public $externalLogins;

/**
* @var SecurityEvents
* @inject
Expand Down Expand Up @@ -225,7 +233,7 @@ public function checkUpdateProfile(string $id)
* description="New password of current user")
* @Param(type="post", name="passwordConfirm", required=false, validation="string:1..",
* description="Confirmation of new password of current user")
* @Param(type="post", name="gravatarUrlEnabled", validation="bool",
* @Param(type="post", name="gravatarUrlEnabled", validation="bool", required=false,
* description="Enable or disable gravatar profile image")
* @throws WrongCredentialsException
* @throws NotFoundException
Expand Down Expand Up @@ -254,7 +262,10 @@ public function actionUpdateProfile(string $id)
$req->getPost("passwordConfirm")
);

$user->setGravatar(filter_var($req->getPost("gravatarUrlEnabled"), FILTER_VALIDATE_BOOLEAN));
$gravatarUrlEnabled = $req->getPost("gravatarUrlEnabled");
if ($gravatarUrlEnabled !== null) { // null or missing value -> no update
$user->setGravatar(filter_var($gravatarUrlEnabled, FILTER_VALIDATE_BOOLEAN));
}

// make changes permanent
$this->users->flush();
Expand Down Expand Up @@ -291,7 +302,7 @@ private function changeUserEmail(User $user, ?string $email)
}

if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
throw new InvalidArgumentException("Provided email is not in correct format");
throw new InvalidArgumentException('email', "Provided email is not in correct format");
}

$oldEmail = $user->getEmail();
Expand Down Expand Up @@ -372,7 +383,7 @@ private function changeUserPassword(

if (!$password || !$passwordConfirm) {
// old password was provided but the new ones not, illegal state
throw new InvalidArgumentException("New password was not provided");
throw new InvalidArgumentException('password|passwordConfirm', "New password was not provided");
}

// passwords need to be handled differently
Expand Down Expand Up @@ -761,4 +772,78 @@ public function actionSetAllowed(string $id)
$this->users->flush();
$this->sendSuccessResponse($this->userViewFactory->getUser($user));
}

public function checkUpdateExternalLogin(string $id, string $service)
{
$user = $this->users->findOrThrow($id);
if (!$this->userAcl->canSetExternalIds($user)) {
throw new ForbiddenRequestException();
}

// in the future, we might consider cross-checking the service ID
}

/**
* Add or update existing external ID of given authentication service.
* @POST
* @param string $id identifier of the user
* @param string $service identifier of the authentication service (login type)
* @Param(type="post", name="externalId", validation="string:1..128")
* @throws InvalidArgumentException
*/
public function actionUpdateExternalLogin(string $id, string $service)
{
$user = $this->users->findOrThrow($id);

// make sure the external ID is not used for another user
$externalId = $this->getRequest()->getPost("externalId");
$anotherUser = $this->externalLogins->getUser($service, $externalId);
if ($anotherUser) {
if ($anotherUser->getId() !== $id) {
// oopsie, this external ID is alreay used for a different user
throw new InvalidArgumentException('externalId', "This ID is already used by another user.");
}
// otherwise the external ID is already set to this user, so there is nothing to change...
} else {
// create/update external login entry
$login = $this->externalLogins->findByUser($user, $service);
if ($login) {
$login->setExternalId($externalId);
} else {
$login = new ExternalLogin($user, $service, $externalId);
}

$this->externalLogins->persist($login);
$this->users->refresh($user);
}

$this->sendSuccessResponse($this->userViewFactory->getUser($user));
}

public function checkRemoveExternalLogin(string $id, string $service)
{
$user = $this->users->findOrThrow($id);
if (!$this->userAcl->canSetExternalIds($user)) {
throw new ForbiddenRequestException();
}

// in the future, we might consider cross-checking the service ID
}

/**
* Remove external ID of given authentication service.
* @DELETE
* @param string $id identifier of the user
* @param string $service identifier of the authentication service (login type)
*/
public function actionRemoveExternalLogin(string $id, string $service)
{
$user = $this->users->findOrThrow($id);
$login = $this->externalLogins->findByUser($user, $service);
if ($login) {
$this->externalLogins->remove($login);
$this->users->refresh($user);
}
$this->sendSuccessResponse($this->userViewFactory->getUser($user));
}
}
2 changes: 2 additions & 0 deletions app/V1Module/router/RouterFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,8 @@ private static function createUsersRoutes(string $prefix): RouteList
$router[] = new PostRoute("$prefix/<id>/create-local", "Users:createLocalAccount");
$router[] = new PostRoute("$prefix/<id>/role", "Users:setRole");
$router[] = new PostRoute("$prefix/<id>/allowed", "Users:setAllowed");
$router[] = new PostRoute("$prefix/<id>/external-login/<service>", "Users:updateExternalLogin");
$router[] = new DeleteRoute("$prefix/<id>/external-login/<service>", "Users:removeExternalLogin");
$router[] = new GetRoute("$prefix/<id>/calendar-tokens", "UserCalendars:userCalendars");
$router[] = new PostRoute("$prefix/<id>/calendar-tokens", "UserCalendars:createCalendar");
$router[] = new GetRoute("$prefix/<id>/pending-reviews", "AssignmentSolutionReviews:pending");
Expand Down
2 changes: 2 additions & 0 deletions app/V1Module/security/ACL/IUserPermissions.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ public function canSetRole(User $user): bool;

public function canSetIsAllowed(User $user): bool;

public function canSetExternalIds(User $user): bool;

public function canInvalidateTokens(User $user): bool;

public function canForceChangePassword(User $user): bool;
Expand Down
6 changes: 3 additions & 3 deletions app/V1Module/security/AccessManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,15 @@ public function getUser(AccessToken $token): User
if (!$user) {
throw new ForbiddenRequestException(
"Forbidden Request - User does not exist",
IResponse::S403_FORBIDDEN,
IResponse::S403_Forbidden,
FrontendErrorMappings::E403_001__USER_NOT_EXIST
);
}

if (!$user->isAllowed()) {
throw new ForbiddenRequestException(
"Forbidden Request - User account was disabled",
IResponse::S403_FORBIDDEN,
IResponse::S403_Forbidden,
FrontendErrorMappings::E403_002__USER_NOT_ALLOWED
);
}
Expand Down Expand Up @@ -148,7 +148,7 @@ public function issueToken(
if (!$user->isAllowed()) {
throw new ForbiddenRequestException(
"Forbidden Request - User account was disabled",
IResponse::S403_FORBIDDEN,
IResponse::S403_Forbidden,
FrontendErrorMappings::E403_002__USER_NOT_ALLOWED
);
}
Expand Down
9 changes: 9 additions & 0 deletions app/V1Module/security/CredentialsAuthenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@

use App\Exceptions\FrontendErrorMappings;
use App\Exceptions\WrongCredentialsException;
use App\Exceptions\ForbiddenRequestException;
use App\Model\Entity\User;
use App\Model\Repository\Logins;
use Nette;
use Nette\Security\Passwords;
use Nette\Http\IResponse;

class CredentialsAuthenticator
{
Expand Down Expand Up @@ -40,8 +42,15 @@ public function authenticate(string $username, string $password)
"The username or password is incorrect.",
FrontendErrorMappings::E400_101__WRONG_CREDENTIALS_LOCAL
);
} elseif (!$user->isAllowed()) {
throw new ForbiddenRequestException(
"Forbidden Request - User account was disabled",
IResponse::S403_Forbidden,
FrontendErrorMappings::E403_002__USER_NOT_ALLOWED
);
}


return $user;
}
}
3 changes: 2 additions & 1 deletion app/config/config.local.neon.example
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ parameters:
externalAuthenticators:
- name: "cas-auth-ext"
jwtSecret: "secretStringSharedWithExternAuth"
jwtAlgorithm: HS256 # optional, HS256 is default
expiration: 60 # seconds passed since iat
usedAlgorithm: HS256 # optional, HS256 is default
extraIds: [] # additional service types whose IDs may be provided as extra IDs in the auth token

emails:
footerUrl: "%webapp.address%"
Expand Down
1 change: 1 addition & 0 deletions app/config/permissions.neon
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ permissions:
- updateProfile
- updatePersonalData
- setIsAllowed
- setExternalIds
- createLocalAccount
- invalidateTokens

Expand Down
Loading