Skip to content

Commit 201eefa

Browse files
committed
Use token generator to authenticate just provisioned facility superadmin
1 parent 2f545f3 commit 201eefa

File tree

6 files changed

+189
-81
lines changed

6 files changed

+189
-81
lines changed

kolibri/core/auth/api.py

Lines changed: 147 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
from kolibri.core.query import SQCount
9696
from kolibri.core.serializers import HexOnlyUUIDField
9797
from kolibri.core.utils.pagination import ValuesViewsetPageNumberPagination
98+
from kolibri.core.utils.token_generator import TokenGenerator
9899
from kolibri.core.utils.urls import reverse_path
99100
from kolibri.plugins.app.utils import interface
100101
from kolibri.utils.urls import validator
@@ -921,73 +922,98 @@ def post(self, request):
921922
return Response()
922923

923924

924-
@method_decorator([ensure_csrf_cookie], name="dispatch")
925-
class SessionViewSet(viewsets.ViewSet):
926-
def _check_os_user(self, request, username):
927-
auth_token = request.COOKIES.get(APP_AUTH_TOKEN_COOKIE_NAME)
928-
if auth_token:
929-
try:
930-
user = FacilityUser.objects.get_or_create_os_user(auth_token)
931-
if user is not None and user.username == username:
932-
return user
933-
except ValidationError as e:
934-
logger.error(e)
925+
class CreateSessionSerializer(serializers.Serializer):
926+
username = serializers.CharField(required=False, default=None)
927+
user_id = HexOnlyUUIDField(required=False, default=None)
928+
password = serializers.CharField(
929+
default="",
930+
write_only=True,
931+
required=False,
932+
allow_blank=True,
933+
)
934+
facility = serializers.PrimaryKeyRelatedField(
935+
queryset=Facility.objects.all(),
936+
default=Facility.get_default_facility,
937+
required=False,
938+
)
939+
auth_token = serializers.CharField(required=False, default=None)
935940

936-
def create(self, request):
937-
username = request.data.get("username", "")
938-
password = request.data.get("password", "")
939-
facility_id = request.data.get("facility", None)
941+
def validate(self, attrs):
942+
username = attrs.get("username")
943+
password = attrs.get("password")
944+
facility = attrs.get("facility")
945+
user_id = attrs.get("user_id")
946+
auth_token = attrs.get("auth_token")
940947

941-
# Only enforce this when running in an app
942-
if (
943-
interface.enabled
944-
and not allow_other_browsers_to_connect()
945-
and not valid_app_key_on_request(request)
946-
):
947-
return Response(
948-
[{"id": error_constants.INVALID_CREDENTIALS, "metadata": {}}],
949-
status=status.HTTP_401_UNAUTHORIZED,
950-
)
948+
request = self.context.get("request")
951949

952950
user = None
951+
952+
# OS User authentication
953953
if interface.enabled and valid_app_key_on_request(request):
954954
# If we are in app context, then try to get the automatically created OS User
955955
# if it matches the username, without needing a password.
956956
user = self._check_os_user(request, username)
957+
958+
# user_id/auth_token authentication
959+
if user is None and user_id and auth_token:
960+
if TokenGenerator().check_token(user_id, auth_token):
961+
user = FacilityUser.objects.filter(
962+
id=user_id, facility=facility
963+
).first()
964+
965+
# username/password authentication
957966
if user is None:
958967
# Otherwise attempt full authentication
959-
user = authenticate(
960-
username=username, password=password, facility=facility_id
961-
)
968+
user = authenticate(username=username, password=password, facility=facility)
969+
962970
if user is not None and user.is_active:
963-
# Correct password, and the user is marked "active"
964-
login(request, user)
965-
# Success!
966-
return self.get_session_response(request)
967-
# Otherwise, try to give a helpful error message
971+
attrs["user"] = user
972+
return attrs
973+
974+
# Otherwise, throw a meaningful validation error
975+
self._throw_validation_error(username, password, facility)
976+
977+
def _check_os_user(self, request, username):
978+
app_auth_token = request.COOKIES.get(APP_AUTH_TOKEN_COOKIE_NAME)
979+
if app_auth_token:
980+
try:
981+
user = FacilityUser.objects.get_or_create_os_user(app_auth_token)
982+
if user is not None and user.username == username:
983+
return user
984+
except ValidationError as e:
985+
logger.error(e)
986+
987+
def _throw_validation_error(self, username, password, facility):
988+
"""
989+
Throw a RestValidationError with a helpful error message
990+
depending on what went wrong with authentication.
991+
"""
968992
# Find the FacilityUser we're looking for
969993
try:
970994
unauthenticated_user = FacilityUser.objects.get(
971-
username__iexact=username, facility=facility_id
995+
username__iexact=username, facility=facility
972996
)
973997
except (ValueError, ObjectDoesNotExist):
974-
return Response(
975-
[
976-
{
977-
"id": error_constants.NOT_FOUND,
978-
"metadata": {
979-
"field": "username",
980-
"message": "Username not found.",
981-
},
982-
}
983-
],
984-
status=status.HTTP_400_BAD_REQUEST,
998+
raise RestValidationError(
999+
detail={
1000+
"username": [
1001+
{
1002+
"id": error_constants.NOT_FOUND,
1003+
"metadata": {
1004+
"field": "username",
1005+
"message": "Username not found.",
1006+
},
1007+
}
1008+
]
1009+
}
9851010
)
9861011
except FacilityUser.MultipleObjectsReturned:
9871012
# Handle case of multiple matching usernames
9881013
unauthenticated_user = FacilityUser.objects.filter(
989-
username__exact=username, facility=facility_id
1014+
username__exact=username, facility=facility
9901015
).first()
1016+
9911017
if unauthenticated_user.password == NOT_SPECIFIED and not hasattr(
9921018
unauthenticated_user, "os_user"
9931019
):
@@ -996,42 +1022,92 @@ def create(self, request):
9961022
# it is enabled again.
9971023
# Alternatively, they may have been created as an OSUser for automatic login with an
9981024
# authentication token. If this is the case, then we do not allow for the password to be set.
999-
return Response(
1000-
[
1001-
{
1002-
"id": error_constants.PASSWORD_NOT_SPECIFIED,
1003-
"metadata": {
1004-
"field": "password",
1005-
"message": "Username is valid, but password needs to be set before login.",
1006-
},
1007-
}
1008-
],
1009-
status=status.HTTP_400_BAD_REQUEST,
1025+
raise RestValidationError(
1026+
detail={
1027+
"password": [
1028+
{
1029+
"id": error_constants.PASSWORD_NOT_SPECIFIED,
1030+
"metadata": {
1031+
"field": "password",
1032+
"message": "Username is valid, but password needs to be set before login.",
1033+
},
1034+
}
1035+
]
1036+
}
10101037
)
1038+
10111039
if (
10121040
not password
10131041
and FacilityUser.objects.filter(
1014-
username__iexact=username, facility=facility_id
1042+
username__iexact=username, facility=facility
10151043
).exists()
10161044
):
10171045
# Password was missing, but username is valid, prompt to give password
1018-
return Response(
1019-
[
1046+
raise RestValidationError(
1047+
detail={
1048+
"password": [
1049+
{
1050+
"id": error_constants.MISSING_PASSWORD,
1051+
"metadata": {
1052+
"field": "password",
1053+
"message": "Username is valid, but password is missing.",
1054+
},
1055+
}
1056+
]
1057+
}
1058+
)
1059+
1060+
# If no other error message was raised, then throw a generic invalid credentials message
1061+
raise RestValidationError(
1062+
detail={
1063+
"non_field_errors": [
10201064
{
1021-
"id": error_constants.MISSING_PASSWORD,
1022-
"metadata": {
1023-
"field": "password",
1024-
"message": "Username is valid, but password is missing.",
1025-
},
1065+
"id": error_constants.INVALID_CREDENTIALS,
1066+
"metadata": {},
10261067
}
1027-
],
1028-
status=status.HTTP_400_BAD_REQUEST,
1068+
]
1069+
}
1070+
)
1071+
1072+
1073+
@method_decorator([ensure_csrf_cookie], name="dispatch")
1074+
class SessionViewSet(viewsets.ViewSet):
1075+
def create(self, request):
1076+
# Only enforce this when running in an app
1077+
if (
1078+
interface.enabled
1079+
and not allow_other_browsers_to_connect()
1080+
and not valid_app_key_on_request(request)
1081+
):
1082+
return Response(
1083+
[{"id": error_constants.INVALID_CREDENTIALS, "metadata": {}}],
1084+
status=status.HTTP_401_UNAUTHORIZED,
10291085
)
1030-
# Respond with error
1031-
return Response(
1032-
[{"id": error_constants.INVALID_CREDENTIALS, "metadata": {}}],
1033-
status=status.HTTP_401_UNAUTHORIZED,
1086+
1087+
serializer = CreateSessionSerializer(
1088+
data=request.data, context={"request": request}
10341089
)
1090+
if serializer.is_valid():
1091+
user = serializer.validated_data["user"]
1092+
login(request, user)
1093+
return self.get_session_response(request)
1094+
1095+
errors = serializer.errors
1096+
return self._get_error_response(errors)
1097+
1098+
def _get_error_response(self, errors):
1099+
"""
1100+
Helper method to construct a standardized error response.
1101+
"""
1102+
error_list = []
1103+
response_status = status.HTTP_400_BAD_REQUEST
1104+
for field, field_errors in errors.items():
1105+
for error in field_errors:
1106+
error_list.append(error)
1107+
if error.get("id") == error_constants.INVALID_CREDENTIALS:
1108+
response_status = status.HTTP_401_UNAUTHORIZED
1109+
1110+
return Response(error_list, status=response_status)
10351111

10361112
def destroy(self, request, pk=None):
10371113
logout(request)

kolibri/core/device/tasks.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from kolibri.core.tasks.permissions import FirstProvisioning
2323
from kolibri.core.tasks.utils import get_current_job
2424
from kolibri.core.tasks.validation import JobValidator
25+
from kolibri.core.utils.token_generator import TokenGenerator
2526
from kolibri.plugins.app.utils import GET_OS_USER
2627
from kolibri.plugins.app.utils import interface
2728

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

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

147+
superuser_created = False
148+
146149
if "superuser" in data:
147150
superuser_data = data["superuser"]
148151
# We've imported a facility if the username exists
@@ -159,6 +162,7 @@ def provisiondevice(**data): # noqa C901
159162
facility=facility,
160163
full_name=superuser_data.get("full_name"),
161164
)
165+
superuser_created = True
162166
except Exception:
163167
raise ParseError(
164168
"`username`, `password`, or `full_name` are missing in `superuser`"
@@ -223,7 +227,19 @@ def provisiondevice(**data): # noqa C901
223227

224228
job = get_current_job()
225229
if job:
226-
job.update_metadata(
227-
facility_id=facility.id,
228-
username=superuser.username if superuser else None,
229-
)
230+
updates = {
231+
"facility_id": facility.id,
232+
"superuser_id": superuser.id if superuser else None,
233+
"username": superuser.username if superuser else None,
234+
}
235+
236+
# If superuser was imported, and learners are not allowed to log in
237+
# without a password, then we will need a token so that the frontend can
238+
# authenticate the superuser in case it does not know the password.
239+
if (
240+
not superuser_created
241+
and not facility.dataset.learner_can_login_with_no_password
242+
):
243+
updates["auth_token"] = TokenGenerator().make_token(superuser.id)
244+
245+
job.update_metadata(**updates)
File renamed without changes.

kolibri/plugins/setup_wizard/assets/src/views/onboarding-forms/SettingUpKolibri.vue

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -212,29 +212,45 @@
212212
async pollProvisionTask() {
213213
try {
214214
const tasks = await TaskResource.list({ queue: PROVISION_TASK_QUEUE });
215-
const [task] = tasks || [];
215+
const task = tasks[tasks.length - 1]; // Get the most recent task
216216
if (!task) {
217217
throw new Error('Device provisioning task not found');
218218
}
219219
if (task.status === TaskStatuses.COMPLETED) {
220220
const facilityId = task.extra_metadata.facility_id;
221+
221222
// Taking the username from the task extra metadata in case the superuser was created
222223
// from the OS user.
223224
const username = task.extra_metadata.username;
225+
226+
// Taking the auth token and superuser ID from the task extra metadata in case
227+
// the superuser was imported, and we don't have a password for them. In this case,
228+
// the auth token will allow us to log them in.
229+
const authToken = task.extra_metadata.auth_token;
230+
const superuserId = task.extra_metadata.superuser_id;
231+
224232
this.clearPollingTasks();
225233
this.wrapOnboarding();
226234
if (this.deviceProvisioningData.superuser || this.userBasedOnOs) {
227235
const { password } = this.deviceProvisioningData.superuser || {};
228-
return this.kolibriLogin({
229-
facilityId,
236+
const error = await this.kolibriLogin({
237+
facility: facilityId,
230238
username,
231239
password,
240+
auth_token: authToken,
241+
user_id: superuserId,
232242
});
243+
if (error) {
244+
// If we get an error logging in, just redirect to the sign-in page
245+
return redirectBrowser();
246+
}
247+
return;
233248
} else {
234249
return redirectBrowser();
235250
}
236251
} else if (task.status === TaskStatuses.FAILED) {
237-
this.$store.dispatch('handleApiError', { error: task.error });
252+
this.clearPollingTasks();
253+
this.$store.dispatch('handleApiError', { error: task.traceback });
238254
} else {
239255
setTimeout(() => {
240256
this.pollProvisionTask();

kolibri/plugins/user_profile/tasks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from rest_framework import serializers
44
from rest_framework.exceptions import ValidationError
55

6-
from .utils import TokenGenerator
76
from kolibri.core import error_constants
87
from kolibri.core.auth.constants import role_kinds
98
from kolibri.core.auth.middleware import clear_user_cache_on_delete
@@ -25,6 +24,7 @@
2524
from kolibri.core.tasks.permissions import IsSuperAdmin
2625
from kolibri.core.tasks.permissions import PermissionsFromAny
2726
from kolibri.core.tasks.utils import get_current_job
27+
from kolibri.core.utils.token_generator import TokenGenerator
2828
from kolibri.core.utils.urls import reverse_path
2929
from kolibri.utils.translation import gettext as _
3030

kolibri/plugins/user_profile/viewsets.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
from rest_framework.response import Response
33
from rest_framework.views import APIView
44

5-
from .utils import TokenGenerator
65
from kolibri.core.auth.models import FacilityUser
6+
from kolibri.core.utils.token_generator import TokenGenerator
77

88

99
class OnMyOwnSetupViewset(APIView):

0 commit comments

Comments
 (0)