Skip to content

Commit 2e1ffaf

Browse files
committed
Use token generator to authenticate just provisioned facility superadmin
1 parent bf23b0a commit 2e1ffaf

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
@@ -103,6 +103,7 @@
103103
from kolibri.core.serializers import HexOnlyUUIDField
104104
from kolibri.core.tasks.exceptions import JobRunning
105105
from kolibri.core.utils.pagination import ValuesViewsetPageNumberPagination
106+
from kolibri.core.utils.token_generator import TokenGenerator
106107
from kolibri.core.utils.urls import reverse_path
107108
from kolibri.plugins.app.utils import interface
108109
from kolibri.utils.urls import validator
@@ -1145,73 +1146,98 @@ def post(self, request):
11451146
return Response()
11461147

11471148

1148-
@method_decorator([ensure_csrf_cookie], name="dispatch")
1149-
class SessionViewSet(viewsets.ViewSet):
1150-
def _check_os_user(self, request, username):
1151-
auth_token = request.COOKIES.get(APP_AUTH_TOKEN_COOKIE_NAME)
1152-
if auth_token:
1153-
try:
1154-
user = FacilityUser.objects.get_or_create_os_user(auth_token)
1155-
if user is not None and user.username == username:
1156-
return user
1157-
except ValidationError as e:
1158-
logger.error(e)
1149+
class CreateSessionSerializer(serializers.Serializer):
1150+
username = serializers.CharField(required=False, default=None)
1151+
user_id = HexOnlyUUIDField(required=False, default=None)
1152+
password = serializers.CharField(
1153+
default="",
1154+
write_only=True,
1155+
required=False,
1156+
allow_blank=True,
1157+
)
1158+
facility = serializers.PrimaryKeyRelatedField(
1159+
queryset=Facility.objects.all(),
1160+
default=Facility.get_default_facility,
1161+
required=False,
1162+
)
1163+
auth_token = serializers.CharField(required=False, default=None)
11591164

1160-
def create(self, request):
1161-
username = request.data.get("username", "")
1162-
password = request.data.get("password", "")
1163-
facility_id = request.data.get("facility", None)
1165+
def validate(self, attrs):
1166+
username = attrs.get("username")
1167+
password = attrs.get("password")
1168+
facility = attrs.get("facility")
1169+
user_id = attrs.get("user_id")
1170+
auth_token = attrs.get("auth_token")
11641171

1165-
# Only enforce this when running in an app
1166-
if (
1167-
interface.enabled
1168-
and not allow_other_browsers_to_connect()
1169-
and not valid_app_key_on_request(request)
1170-
):
1171-
return Response(
1172-
[{"id": error_constants.INVALID_CREDENTIALS, "metadata": {}}],
1173-
status=status.HTTP_401_UNAUTHORIZED,
1174-
)
1172+
request = self.context.get("request")
11751173

11761174
user = None
1175+
1176+
# OS User authentication
11771177
if interface.enabled and valid_app_key_on_request(request):
11781178
# If we are in app context, then try to get the automatically created OS User
11791179
# if it matches the username, without needing a password.
11801180
user = self._check_os_user(request, username)
1181+
1182+
# user_id/auth_token authentication
1183+
if user is None and user_id and auth_token:
1184+
if TokenGenerator().check_token(user_id, auth_token):
1185+
user = FacilityUser.objects.filter(
1186+
id=user_id, facility=facility
1187+
).first()
1188+
1189+
# username/password authentication
11811190
if user is None:
11821191
# Otherwise attempt full authentication
1183-
user = authenticate(
1184-
username=username, password=password, facility=facility_id
1185-
)
1192+
user = authenticate(username=username, password=password, facility=facility)
1193+
11861194
if user is not None and user.is_active:
1187-
# Correct password, and the user is marked "active"
1188-
login(request, user)
1189-
# Success!
1190-
return self.get_session_response(request)
1191-
# Otherwise, try to give a helpful error message
1195+
attrs["user"] = user
1196+
return attrs
1197+
1198+
# Otherwise, throw a meaningful validation error
1199+
self._throw_validation_error(username, password, facility)
1200+
1201+
def _check_os_user(self, request, username):
1202+
app_auth_token = request.COOKIES.get(APP_AUTH_TOKEN_COOKIE_NAME)
1203+
if app_auth_token:
1204+
try:
1205+
user = FacilityUser.objects.get_or_create_os_user(app_auth_token)
1206+
if user is not None and user.username == username:
1207+
return user
1208+
except ValidationError as e:
1209+
logger.error(e)
1210+
1211+
def _throw_validation_error(self, username, password, facility):
1212+
"""
1213+
Throw a RestValidationError with a helpful error message
1214+
depending on what went wrong with authentication.
1215+
"""
11921216
# Find the FacilityUser we're looking for
11931217
try:
11941218
unauthenticated_user = FacilityUser.objects.get(
1195-
username__iexact=username, facility=facility_id
1219+
username__iexact=username, facility=facility
11961220
)
11971221
except (ValueError, ObjectDoesNotExist):
1198-
return Response(
1199-
[
1200-
{
1201-
"id": error_constants.NOT_FOUND,
1202-
"metadata": {
1203-
"field": "username",
1204-
"message": "Username not found.",
1205-
},
1206-
}
1207-
],
1208-
status=status.HTTP_400_BAD_REQUEST,
1222+
raise RestValidationError(
1223+
detail={
1224+
"username": [
1225+
{
1226+
"id": error_constants.NOT_FOUND,
1227+
"metadata": {
1228+
"field": "username",
1229+
"message": "Username not found.",
1230+
},
1231+
}
1232+
]
1233+
}
12091234
)
12101235
except FacilityUser.MultipleObjectsReturned:
12111236
# Handle case of multiple matching usernames
12121237
unauthenticated_user = FacilityUser.objects.filter(
1213-
username__exact=username, facility=facility_id
1238+
username__exact=username, facility=facility
12141239
).first()
1240+
12151241
if unauthenticated_user.password == NOT_SPECIFIED and not hasattr(
12161242
unauthenticated_user, "os_user"
12171243
):
@@ -1220,42 +1246,92 @@ def create(self, request):
12201246
# it is enabled again.
12211247
# Alternatively, they may have been created as an OSUser for automatic login with an
12221248
# authentication token. If this is the case, then we do not allow for the password to be set.
1223-
return Response(
1224-
[
1225-
{
1226-
"id": error_constants.PASSWORD_NOT_SPECIFIED,
1227-
"metadata": {
1228-
"field": "password",
1229-
"message": "Username is valid, but password needs to be set before login.",
1230-
},
1231-
}
1232-
],
1233-
status=status.HTTP_400_BAD_REQUEST,
1249+
raise RestValidationError(
1250+
detail={
1251+
"password": [
1252+
{
1253+
"id": error_constants.PASSWORD_NOT_SPECIFIED,
1254+
"metadata": {
1255+
"field": "password",
1256+
"message": "Username is valid, but password needs to be set before login.",
1257+
},
1258+
}
1259+
]
1260+
}
12341261
)
1262+
12351263
if (
12361264
not password
12371265
and FacilityUser.objects.filter(
1238-
username__iexact=username, facility=facility_id
1266+
username__iexact=username, facility=facility
12391267
).exists()
12401268
):
12411269
# Password was missing, but username is valid, prompt to give password
1242-
return Response(
1243-
[
1270+
raise RestValidationError(
1271+
detail={
1272+
"password": [
1273+
{
1274+
"id": error_constants.MISSING_PASSWORD,
1275+
"metadata": {
1276+
"field": "password",
1277+
"message": "Username is valid, but password is missing.",
1278+
},
1279+
}
1280+
]
1281+
}
1282+
)
1283+
1284+
# If no other error message was raised, then throw a generic invalid credentials message
1285+
raise RestValidationError(
1286+
detail={
1287+
"non_field_errors": [
12441288
{
1245-
"id": error_constants.MISSING_PASSWORD,
1246-
"metadata": {
1247-
"field": "password",
1248-
"message": "Username is valid, but password is missing.",
1249-
},
1289+
"id": error_constants.INVALID_CREDENTIALS,
1290+
"metadata": {},
12501291
}
1251-
],
1252-
status=status.HTTP_400_BAD_REQUEST,
1292+
]
1293+
}
1294+
)
1295+
1296+
1297+
@method_decorator([ensure_csrf_cookie], name="dispatch")
1298+
class SessionViewSet(viewsets.ViewSet):
1299+
def create(self, request):
1300+
# Only enforce this when running in an app
1301+
if (
1302+
interface.enabled
1303+
and not allow_other_browsers_to_connect()
1304+
and not valid_app_key_on_request(request)
1305+
):
1306+
return Response(
1307+
[{"id": error_constants.INVALID_CREDENTIALS, "metadata": {}}],
1308+
status=status.HTTP_401_UNAUTHORIZED,
12531309
)
1254-
# Respond with error
1255-
return Response(
1256-
[{"id": error_constants.INVALID_CREDENTIALS, "metadata": {}}],
1257-
status=status.HTTP_401_UNAUTHORIZED,
1310+
1311+
serializer = CreateSessionSerializer(
1312+
data=request.data, context={"request": request}
12581313
)
1314+
if serializer.is_valid():
1315+
user = serializer.validated_data["user"]
1316+
login(request, user)
1317+
return self.get_session_response(request)
1318+
1319+
errors = serializer.errors
1320+
return self._get_error_response(errors)
1321+
1322+
def _get_error_response(self, errors):
1323+
"""
1324+
Helper method to construct a standardized error response.
1325+
"""
1326+
error_list = []
1327+
response_status = status.HTTP_400_BAD_REQUEST
1328+
for field, field_errors in errors.items():
1329+
for error in field_errors:
1330+
error_list.append(error)
1331+
if error.get("id") == error_constants.INVALID_CREDENTIALS:
1332+
response_status = status.HTTP_401_UNAUTHORIZED
1333+
1334+
return Response(error_list, status=response_status)
12591335

12601336
def destroy(self, request, pk=None):
12611337
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
@@ -215,29 +215,45 @@
215215
async pollProvisionTask() {
216216
try {
217217
const tasks = await TaskResource.list({ queue: PROVISION_TASK_QUEUE });
218-
const [task] = tasks || [];
218+
const task = tasks[tasks.length - 1]; // Get the most recent task
219219
if (!task) {
220220
throw new Error('Device provisioning task not found');
221221
}
222222
if (task.status === TaskStatuses.COMPLETED) {
223223
const facilityId = task.extra_metadata.facility_id;
224+
224225
// Taking the username from the task extra metadata in case the superuser was created
225226
// from the OS user.
226227
const username = task.extra_metadata.username;
228+
229+
// Taking the auth token and superuser ID from the task extra metadata in case
230+
// the superuser was imported, and we don't have a password for them. In this case,
231+
// the auth token will allow us to log them in.
232+
const authToken = task.extra_metadata.auth_token;
233+
const superuserId = task.extra_metadata.superuser_id;
234+
227235
this.clearPollingTasks();
228236
this.wrapOnboarding();
229237
if (this.deviceProvisioningData.superuser || this.userBasedOnOs) {
230238
const { password } = this.deviceProvisioningData.superuser || {};
231-
return this.login({
232-
facilityId,
239+
const error = await this.login({
240+
facility: facilityId,
233241
username,
234242
password,
243+
auth_token: authToken,
244+
user_id: superuserId,
235245
});
246+
if (error) {
247+
// If we get an error logging in, just redirect to the sign-in page
248+
return redirectBrowser();
249+
}
250+
return;
236251
} else {
237252
return redirectBrowser();
238253
}
239254
} else if (task.status === TaskStatuses.FAILED) {
240-
this.$store.dispatch('handleApiError', { error: task.error });
255+
this.clearPollingTasks();
256+
this.$store.dispatch('handleApiError', { error: task.traceback });
241257
} else {
242258
setTimeout(() => {
243259
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)