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
218 changes: 147 additions & 71 deletions kolibri/core/auth/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
from kolibri.core.query import SQCount
from kolibri.core.serializers import HexOnlyUUIDField
from kolibri.core.utils.pagination import ValuesViewsetPageNumberPagination
from kolibri.core.utils.token_generator import TokenGenerator
from kolibri.core.utils.urls import reverse_path
from kolibri.plugins.app.utils import interface
from kolibri.utils.urls import validator
Expand Down Expand Up @@ -921,73 +922,98 @@ def post(self, request):
return Response()


@method_decorator([ensure_csrf_cookie], name="dispatch")
class SessionViewSet(viewsets.ViewSet):
def _check_os_user(self, request, username):
auth_token = request.COOKIES.get(APP_AUTH_TOKEN_COOKIE_NAME)
if auth_token:
try:
user = FacilityUser.objects.get_or_create_os_user(auth_token)
if user is not None and user.username == username:
return user
except ValidationError as e:
logger.error(e)
class CreateSessionSerializer(serializers.Serializer):
username = serializers.CharField(required=False, default=None)
user_id = HexOnlyUUIDField(required=False, default=None)
password = serializers.CharField(
default="",
write_only=True,
required=False,
allow_blank=True,
)
facility = serializers.PrimaryKeyRelatedField(
queryset=Facility.objects.all(),
default=Facility.get_default_facility,
required=False,
)
auth_token = serializers.CharField(required=False, default=None)

def create(self, request):
username = request.data.get("username", "")
password = request.data.get("password", "")
facility_id = request.data.get("facility", None)
def validate(self, attrs):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't even thinking that we could move so much of the viewset logic into the validate method, but this is a really nice separation of concerns, and provides such a neat interface between the serializer and the viewset, where the viewset only has to worry about the user and nothing else!

username = attrs.get("username")
password = attrs.get("password")
facility = attrs.get("facility")
user_id = attrs.get("user_id")
auth_token = attrs.get("auth_token")

# Only enforce this when running in an app
if (
interface.enabled
and not allow_other_browsers_to_connect()
and not valid_app_key_on_request(request)
):
return Response(
[{"id": error_constants.INVALID_CREDENTIALS, "metadata": {}}],
status=status.HTTP_401_UNAUTHORIZED,
)
request = self.context.get("request")

user = None

# OS User authentication
if interface.enabled and valid_app_key_on_request(request):
# If we are in app context, then try to get the automatically created OS User
# if it matches the username, without needing a password.
user = self._check_os_user(request, username)

# user_id/auth_token authentication
if user is None and user_id and auth_token:
if TokenGenerator().check_token(user_id, auth_token):
user = FacilityUser.objects.filter(
id=user_id, facility=facility
).first()

# username/password authentication
if user is None:
# Otherwise attempt full authentication
user = authenticate(
username=username, password=password, facility=facility_id
)
user = authenticate(username=username, password=password, facility=facility)

if user is not None and user.is_active:
# Correct password, and the user is marked "active"
login(request, user)
# Success!
return self.get_session_response(request)
# Otherwise, try to give a helpful error message
attrs["user"] = user
return attrs

# Otherwise, throw a meaningful validation error
self._throw_validation_error(username, password, facility)

def _check_os_user(self, request, username):
app_auth_token = request.COOKIES.get(APP_AUTH_TOKEN_COOKIE_NAME)
if app_auth_token:
try:
user = FacilityUser.objects.get_or_create_os_user(app_auth_token)
if user is not None and user.username == username:
return user
except ValidationError as e:
logger.error(e)

def _throw_validation_error(self, username, password, facility):
"""
Throw a RestValidationError with a helpful error message
depending on what went wrong with authentication.
"""
# Find the FacilityUser we're looking for
try:
unauthenticated_user = FacilityUser.objects.get(
username__iexact=username, facility=facility_id
username__iexact=username, facility=facility
)
except (ValueError, ObjectDoesNotExist):
return Response(
[
{
"id": error_constants.NOT_FOUND,
"metadata": {
"field": "username",
"message": "Username not found.",
},
}
],
status=status.HTTP_400_BAD_REQUEST,
raise RestValidationError(
detail={
"username": [
{
"id": error_constants.NOT_FOUND,
"metadata": {
"field": "username",
"message": "Username not found.",
},
}
]
}
)
except FacilityUser.MultipleObjectsReturned:
# Handle case of multiple matching usernames
unauthenticated_user = FacilityUser.objects.filter(
username__exact=username, facility=facility_id
username__exact=username, facility=facility
).first()

if unauthenticated_user.password == NOT_SPECIFIED and not hasattr(
unauthenticated_user, "os_user"
):
Expand All @@ -996,42 +1022,92 @@ def create(self, request):
# it is enabled again.
# Alternatively, they may have been created as an OSUser for automatic login with an
# authentication token. If this is the case, then we do not allow for the password to be set.
return Response(
[
{
"id": error_constants.PASSWORD_NOT_SPECIFIED,
"metadata": {
"field": "password",
"message": "Username is valid, but password needs to be set before login.",
},
}
],
status=status.HTTP_400_BAD_REQUEST,
raise RestValidationError(
detail={
"password": [
{
"id": error_constants.PASSWORD_NOT_SPECIFIED,
"metadata": {
"field": "password",
"message": "Username is valid, but password needs to be set before login.",
},
}
]
}
)

if (
not password
and FacilityUser.objects.filter(
username__iexact=username, facility=facility_id
username__iexact=username, facility=facility
).exists()
):
# Password was missing, but username is valid, prompt to give password
return Response(
[
raise RestValidationError(
detail={
"password": [
{
"id": error_constants.MISSING_PASSWORD,
"metadata": {
"field": "password",
"message": "Username is valid, but password is missing.",
},
}
]
}
)

# If no other error message was raised, then throw a generic invalid credentials message
raise RestValidationError(
detail={
"non_field_errors": [
{
"id": error_constants.MISSING_PASSWORD,
"metadata": {
"field": "password",
"message": "Username is valid, but password is missing.",
},
"id": error_constants.INVALID_CREDENTIALS,
"metadata": {},
}
],
status=status.HTTP_400_BAD_REQUEST,
]
}
)


@method_decorator([ensure_csrf_cookie], name="dispatch")
class SessionViewSet(viewsets.ViewSet):
def create(self, request):
# Only enforce this when running in an app
if (
interface.enabled
and not allow_other_browsers_to_connect()
and not valid_app_key_on_request(request)
):
return Response(
[{"id": error_constants.INVALID_CREDENTIALS, "metadata": {}}],
status=status.HTTP_401_UNAUTHORIZED,
)
# Respond with error
return Response(
[{"id": error_constants.INVALID_CREDENTIALS, "metadata": {}}],
status=status.HTTP_401_UNAUTHORIZED,

serializer = CreateSessionSerializer(
data=request.data, context={"request": request}
)
if serializer.is_valid():
user = serializer.validated_data["user"]
login(request, user)
return self.get_session_response(request)

errors = serializer.errors
return self._get_error_response(errors)

def _get_error_response(self, errors):
"""
Helper method to construct a standardized error response.
"""
error_list = []
response_status = status.HTTP_400_BAD_REQUEST
for field, field_errors in errors.items():
for error in field_errors:
error_list.append(error)
if error.get("id") == error_constants.INVALID_CREDENTIALS:
response_status = status.HTTP_401_UNAUTHORIZED

return Response(error_list, status=response_status)

def destroy(self, request, pk=None):
logout(request)
Expand Down
24 changes: 20 additions & 4 deletions kolibri/core/device/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from kolibri.core.tasks.permissions import FirstProvisioning
from kolibri.core.tasks.utils import get_current_job
from kolibri.core.tasks.validation import JobValidator
from kolibri.core.utils.token_generator import TokenGenerator
from kolibri.plugins.app.utils import GET_OS_USER
from kolibri.plugins.app.utils import interface

Expand Down Expand Up @@ -143,6 +144,8 @@ def provisiondevice(**data): # noqa C901

auth_token = data.pop("auth_token", None)

superuser_created = False

if "superuser" in data:
superuser_data = data["superuser"]
# We've imported a facility if the username exists
Expand All @@ -159,6 +162,7 @@ def provisiondevice(**data): # noqa C901
facility=facility,
full_name=superuser_data.get("full_name"),
)
superuser_created = True
except Exception:
raise ParseError(
"`username`, `password`, or `full_name` are missing in `superuser`"
Expand Down Expand Up @@ -223,7 +227,19 @@ def provisiondevice(**data): # noqa C901

job = get_current_job()
if job:
job.update_metadata(
facility_id=facility.id,
username=superuser.username if superuser else None,
)
updates = {
"facility_id": facility.id,
"superuser_id": superuser.id if superuser else None,
"username": superuser.username if superuser else None,
}

# If superuser was imported, and learners are not allowed to log in
# without a password, then we will need a token so that the frontend can
# authenticate the superuser in case it does not know the password.
if (
not superuser_created
and not facility.dataset.learner_can_login_with_no_password
):
updates["auth_token"] = TokenGenerator().make_token(superuser.id)

job.update_metadata(**updates)
Original file line number Diff line number Diff line change
Expand Up @@ -212,29 +212,45 @@
async pollProvisionTask() {
try {
const tasks = await TaskResource.list({ queue: PROVISION_TASK_QUEUE });
const [task] = tasks || [];
const task = tasks[tasks.length - 1]; // Get the most recent task
if (!task) {
throw new Error('Device provisioning task not found');
}
if (task.status === TaskStatuses.COMPLETED) {
const facilityId = task.extra_metadata.facility_id;

// Taking the username from the task extra metadata in case the superuser was created
// from the OS user.
const username = task.extra_metadata.username;

// Taking the auth token and superuser ID from the task extra metadata in case
// the superuser was imported, and we don't have a password for them. In this case,
// the auth token will allow us to log them in.
const authToken = task.extra_metadata.auth_token;
const superuserId = task.extra_metadata.superuser_id;

this.clearPollingTasks();
this.wrapOnboarding();
if (this.deviceProvisioningData.superuser || this.userBasedOnOs) {
const { password } = this.deviceProvisioningData.superuser || {};
return this.kolibriLogin({
facilityId,
const error = await this.kolibriLogin({
facility: facilityId,
username,
password,
auth_token: authToken,
user_id: superuserId,
});
if (error) {
// If we get an error logging in, just redirect to the sign-in page
return redirectBrowser();
}
return;
} else {
return redirectBrowser();
}
} else if (task.status === TaskStatuses.FAILED) {
this.$store.dispatch('handleApiError', { error: task.error });
this.clearPollingTasks();
this.$store.dispatch('handleApiError', { error: task.traceback });
} else {
setTimeout(() => {
this.pollProvisionTask();
Expand Down
2 changes: 1 addition & 1 deletion kolibri/plugins/user_profile/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from rest_framework import serializers
from rest_framework.exceptions import ValidationError

from .utils import TokenGenerator
from kolibri.core import error_constants
from kolibri.core.auth.constants import role_kinds
from kolibri.core.auth.middleware import clear_user_cache_on_delete
Expand All @@ -25,6 +24,7 @@
from kolibri.core.tasks.permissions import IsSuperAdmin
from kolibri.core.tasks.permissions import PermissionsFromAny
from kolibri.core.tasks.utils import get_current_job
from kolibri.core.utils.token_generator import TokenGenerator
from kolibri.core.utils.urls import reverse_path
from kolibri.utils.translation import gettext as _

Expand Down
2 changes: 1 addition & 1 deletion kolibri/plugins/user_profile/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
from rest_framework.response import Response
from rest_framework.views import APIView

from .utils import TokenGenerator
from kolibri.core.auth.models import FacilityUser
from kolibri.core.utils.token_generator import TokenGenerator
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My only other thought here is, could we actually delete the MergedUserLoginViewset, and just update the frontend in the user merging workflow to use your new enhanced SessionViewset instead?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll file a follow up issue for this to look at on develop as cleanup!



class OnMyOwnSetupViewset(APIView):
Expand Down
Loading