9595from kolibri .core .query import SQCount
9696from kolibri .core .serializers import HexOnlyUUIDField
9797from kolibri .core .utils .pagination import ValuesViewsetPageNumberPagination
98+ from kolibri .core .utils .token_generator import TokenGenerator
9899from kolibri .core .utils .urls import reverse_path
99100from kolibri .plugins .app .utils import interface
100101from 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 )
0 commit comments