Rellisreal
BlogHTBDisclaimer

DJG

Introduction


 
To increase my technical skills and understanding, especially while conducting a Penetration Test, i'm often assessing applications where an exploitable vulnerability in one layer (like the front-end) starts of an attack chain and leads to other exploits in other layers (like the database). To help understand attack paths and how applications function, I needed to build the whole system myself.

So, over the last few months I've been brushing up on my programming skills and then setting up an in-house project (That will be featured at a later date).

The full-stack web application that I built utilized a Postgres DB, a Django Back-End (Django Models & Views) and the Django Rest Framework. For my front-end I used a simple Vite React Project with ShadCDN as my headless component library.

While configuring the application, I found the most interesting and engaging part to be the configuration of Authentication & User Auth. The purpose of this post will be to cover authentication in full stack applications, in this case Django.

What is Django?


 
Django is a free and open-source web framework written in Python. Its primary use is to help developers create complex, database-driven solutions quickly and efficiently.

Django uses MVT (Model-View-Template) architecture. This is a design pattern that organizes the solution into three main components:

  • Model: Data handling; we can declare our database models in Django directly.
  • View: Business Logic; allows us to use views/classes to handle web requests and return responses.
  • Template: User Interface; which allows for dynamically generating web pages by inserting data from Django.

MVT

Django Rest Framework


 
Django Rest Framework is a powerful toolkit that allows us to build web APIs within Django, allowing us to expose our database models as RESTful APIs.

Through serialization, view handling, and routing we are able to easily create and manage a back-end API for our application.

The Django Rest Framework has multiple benefits such as inbuilt Authentication Policies, a Web browsable API (Very helpful during development), and built-in pagination support.

Django Custom User Model


 
By default, Django has a default User model that stores data such as username, email, password, groups, and permissions.

Even though the default User model is sufficient for a lot of applications, there are multiple use cases in which configuring a custom user model to record additional data provides more utility (Recording Date Of Birth, Display Names, etc.).

Setting up a Custom User model not only inherits the fields from the base User model but allows you to add custom fields as required. Once created, it can be set as the default User Model by updating it in settings.py.

AUTH_USER_MODEL = user.User

Create a New App:

python manage.py startapp user

Example Model Config:

class User(AbstractUser):
    display_name = models.CharField(max_length=50, blank=True, null=True)
    pass

Overall, due to the compatibility issues of configuring a Custom User Model, it's good practice to always configure one in a separate application.

Django JWT Token


 
The Django REST framework provides multiple different authentication schemas out of the box. Authentication will run at the start of the view (before permission/throttling effects).

We can use the default REST framework authentication by modifying the default authentication class in settings.py.

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework.authentication.BasicAuthentication',
        'rest_framework.authentication.SessionAuthentication',
    ]
}

We can also specify custom authentication classes; in this case, we are creating a custom class to manage our JWT authentication via an access token & a refresh token.

Below is a custom class that checks for an access token, and upon finding a valid access token the class will return the validated token alongside the user who initiated the authentication request.

from rest_framework_simplejwt.authentication import JWTAuthentication

class CookiesJWTAuthentication(JWTAuthentication):
    def authenticate(self, request):
        access_token = request.COOKIES.get('access_token')

        if not access_token:
            return None 

        validated_token = self.get_validated_token(access_token)

        try: 
            user = self.get_user(validated_token)
        except: 
            return None 

        return (user,validated_token)

We can then use this custom class as our default authentication class:

REST_FRAMEWORK = {
   'DEFAULT_AUTHENTICATION_CLASSES': (
        'api.authentication.CookiesJWTAuthentication',
    )
}

Configuring Access & Refresh Cookies


 
TokenObtainPairView is a class that takes a set of user credentials and returns an access and refresh JSON web token (This class then uses the access/refresh token to make a cookie).

Our class, CustomTokenObtainPairView, adds functionality to this. By default, a JSON access token & refresh token is returned; however, they are only returned in the API response data.

To ensure that we can use this for authentication, we separate the key-value pairing of tokens into the access token & refresh token, and from there, we can set a cookie using those values.

In the example below, we have "secure" disabled as we are not hosting the application over HTTPS (This won't cause any issues if authentication is attempted over localhost; however, if you are attempting to authenticate via a DNS/IP address locally, authentication issues will occur).

class CustomTokenObtainPairView(TokenObtainPairView):
    def post(self, request, *args, **kwargs):
        try: 
            # Generates a response using the inherited class values to 
            # serialise JSON Web token.
            response = super().post(request, *args, **kwargs)
            tokens = response.data
            
            # Isolates the token values
            access_token = tokens['access']
            refresh_token = tokens['refresh']

            # If there are no issues / errors it will return a success response 
            # if not its False
            res_class = Response()
            res_class.data = {'success': True}

            #Create access & refresh cookies using the access tokens 
            #generated earlier. 
            res_class.set_cookie(
                key="access_token",
                value=access_token,
                httponly=True, # Preventing XSS
                secure=False,    
                samesite='Lax',
                path='/'  
            )

            res_class.set_cookie(
                key="refresh_token",
                value=refresh_token,
                httponly=True, # Preventing XSS
                secure= False,  
                samesite='Lax',
                path='/'  
            )

            #Returns both cookies 
            return res_class

        except: 
            return Response({'success': False})

 
Whenever the access token times out over a period of time, rather than making the user re-authenticate, we can use a refresh token (that lasts a bit longer before timing out). After confirming a valid refresh token, we generate a new access token and set it as the new access token cookie, allowing the end-user to remain authenticated.

class CustomRefreshToken(TokenRefreshView):
    def post(self, request, *args, **kwargs):
        try: 
            refresh_token = request.COOKIES.get('refresh_token')

            request.data['refresh'] = refresh_token

            # Generates a new token pair
            response = super().post(request, *args, **kwargs)

            tokens = response.data
            
            # Isolates the access token value
            access_token = tokens['access']

            # If there are no issues / errors it will return a refreshed success
            # response if not its False
            res_class = Response()
            res_class.data = {'refreshed': True}

            # Set new refreshed access cookie
            res_class.set_cookie(
                key="access_token",
                value=access_token,
                httponly=True,
                secure=False,  
                samesite='Lax',
                path='/'  
            )

            return res_class

        except:
            return Response({'refreshed': False}) 

Login, Register & General Back-End Views


 
Configuring a view to log out is extremely simple and involves setting up a response that just deletes the existing access & refresh tokens that we have set.

@api_view(['POST'])
def logout(request):
    try: 
        res = Response()
        res.data = {'success': True}
        res.delete_cookie('access_token', path='/', samesite='Lax')
        res.delete_cookie('refresh_token', path='/', samesite='Lax')
        return res
    except: 
        Response({'success': False})

To allow new users to register, we need to allow any user to be able to call the register view. After calling it, the data is passed to a serializer which will de-serialize the JSON HTTP request data back into a complex data type to be stored in the DB.

Essentially, all the view is doing is passing the data through our custom serializer, confirming that the set values are valid, and then saving it to the database.

@api_view(['POST'])
@permission_classes([AllowAny])
def register(request):
    try: 
        serializer = UserRegistrationSerializer(data=request.data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data)
    except: 
         Response(serializer.error)

The serializer below is accepting two fields from request.data: an email field & a password field. We are using the email field to automatically create a display name (This is with the assumption that the emails being set follow the 'FirstName.LastName@provider.com' format).

Display Name is a custom field that we defined in our custom user model. To ensure that users can log in regardless of capitalization, we are ensuring that all login fields (Username & Email) are stored in lowercase.

At the start of the UserRegistrationSerializer, we set the password field to write_only to ensure that the password can be inputted but will not be returned in the output (Good Practice).

class UserRegistrationSerializer(serializers.ModelSerializer):
    password = serializers.CharField(write_only=True)
    class Meta:
        model = User
        fields = ['email', 'password']
    
    def create(self, validated_data):
        self.email = validated_data['email']
        self.display_name = re.match(r"^([a-zA-Z]+)\.([a-zA-Z]+)@[^@]+$", self.email)
        user = User(
            username= self.email.lower(),
            display_name = f"{self.display_name.group(1).capitalize()} {self.display_name.group(2).capitalize()}",
            email=self.email.lower(),
        )
        user.set_password(validated_data['password'])
        user.save()
        return user 

 
From there, end-users are able to log in via the default token pathway by entering the email and password inputted in the register call.

router = DefaultRouter()
router.register(r'users', UserViewSet)
router.register(r'groups', GroupViewSet)

urlpatterns = [
    *router.urls,
    path('token/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('token/refresh/', CustomRefreshToken.as_view(), name='token_refresh'),
    path('logout/', logout, name='logout'),
    path('authenticated/',  is_authenticated, name='is_authenticated'),
    path('register/',  register, name='register'),
]

 
Basic view to check for authentication; the logic is simple as only authenticated users can execute the view (As seen in the set permission_classes). If a user is authenticated, they can execute the function which returns True (This is needed to allow us to configure UseAuth in our front-end).

@api_view(['POST'])
@permission_classes({IsAuthenticated})
def is_authenticated(request):
    return Response({'authenticated': True});

Axois API Calls (Front-End)


 
From the front-end, having a dedicated TSX file that calls Axios functions to invoke API calls can be used.

The API calls are not complex to build as they comprise sending either a POST or a GET request and returning the response. The below async function sends a POST request to the Django API endpoint that holds the refresh token logic (It will refresh the access token if it has expired).

 const refresh_Token = async () => {
    try {
        await axios.post(REFRESH_URL,
        {},
        { withCredentials:true }
    );
        return true;
    } catch (error) {
        return false;
    }
}

 
The below async function covers the login logic; it utilizes the token pathway which accepts a username and password. After successfully authenticating via providing a correct username & password combination in the POST request, an access token & refresh token is provided as a cookie.

export const login = async (email: string, password: string) => {
    const response = await axios.post(LOGIN_URL,
        {username:email.toLowerCase(), password:password},
        { withCredentials:true}
    );
    return response.data.success;
}

 
Async logout function that doesn't provide anything in the request data, but only the cookies from the site (Access & Refresh Token). The output is the deletion of those cookies.

export const logout = async () => {
    try {
        const response = await axios.post(LOGOUT_URL,
            {},
            { withCredentials:true }
        );
        return response.data.success;
    }
    catch (error) {
        console.error(error);
        return false;
    }
}

 
The register async function works in a similar way in which the function will send a post request to the registration API endpoint that we configured in Django. After it gets called and is provided an email and password in the request data, the register function alongside the UserRegistrationSerializer is called, adding a new user to the DB.

For QOL we also added a function call to login, to authenticate the user after registering (NOTE if you need to configure email verification this is not recommended).

export const register = async (email: string, password: string) => {
    try {
    const response = await axios.post(REGISTER_URL,
        {email:email, password:password}
    );
    await login(email,password);
    return response.data.success;
    } catch (error) {
        console.error(error);
        return false;
    }
}

UseAuth (Front-End)


 
Configuring a UseAuth system is significant to prevent users from accessing private pages that should only be accessed by authenticated users. For example, if we have a form, we would like to prevent unauthorized users from accessing it; the same can be said for any generic dashboards that fetch data from the API.

Directly below we have a basic async function that performs an auth check; it returns a true or false value, dependent on if the user is authenticated or not.

export const is_authenticated = async () => {
    try {
        await axios.post(AUTH_URL, {}, { withCredentials:true });
        return true; 
    } catch (error) {
        console.log(error);
        return false;
    }
}

 
The below function is the primary function that we are using to dictate the UseAuth logic.
We prepare the interface for the AuthContextType to include our refresh functionality, our authenticated functionality, and setting a variable for loading in case there is a delay in response.

AuthProvider will call our is_authenticated Axios function to check and confirm if we are authenticated; if not, it will set our auth status to false. We can then return all our child elements attached with an AuthContext (With the isAuthenticated, loading & refreshAuth values).

interface AuthContextType {
    isAuthenticated: boolean;
    loading: boolean;
    refreshAuth: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider = ({ children }) => {
    const [isAuthenticated, setIsAuthenticated] = useState(false)
    const [loading, setLoading] = useState(true)
    const get_authenticated = async () => {
        setLoading(true);
        try {
        const success = await is_authenticated(); 
        setIsAuthenticated(success);
        } catch {
        setIsAuthenticated(false);
        } finally {
        setLoading(false);
        }
    };

    useEffect(() => {
        get_authenticated();
    }, []);

    return (
        <AuthContext.Provider value={{ isAuthenticated, loading, refreshAuth: get_authenticated }}>
        {children}
        </AuthContext.Provider>
    );
    };
export const useAuth = (): AuthContextType => {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within the AuthProvider');
  }
  return context;
};

 
To then utilize the set AuthContext values for only specific routes we need to create a PrivateRoute component. All the primary route component does is check if loading & isAuthenticated is set to false (In that case it will redirect them to the login page); otherwise, it will return the page correctly.

interface PrivateRouteProps {
  children: React.ReactNode;
}

const PrivateRoute = ({ children }: PrivateRouteProps): JSX.Element | null => {
  const { isAuthenticated, loading } = useAuth();
  const nav = useNavigate();

  useEffect(() => {
    if (!loading && !isAuthenticated) {
      nav('/login');
    }
  }, [loading, isAuthenticated, nav]);

  if (loading) {
    return <Heading>Loading...</Heading>;
  }

  return isAuthenticated ? <>{children}</> : null;
};

export default PrivateRoute;

 
This is an example of me applying the PrivateRoutes + AuthProvider in my main.tsx page to have a fully functional UseAuth system for the application

createRoot(document.getElementById('root')!).render(
  <AuthProvider>
    <BrowserRouter>
        <Routes>
          <Route path="/" element={<App />} />
          <Route path="/dashboard" element={<PrivateRoute> <dashboard /> </PrivateRoute>} />
          <Route path="/form" element={<PrivateRoute> <form /> </PrivateRoute>}/>
          <Route path="/login" element={<Login />} />
          <Route path="/logout" element={<Logout />} />
          <Route path="/register" element={<Register />} />
          <Route path="/*" element={<NotFound />} />
        </Routes>
    </BrowserRouter>
  </AuthProvider>,

Conclusion


 
Building the authentication process and developing the web-application from the ground up demystified the 'magic' behind login systems and how web-apps function. I now have a concrete understanding of how sessions, tokens, and cookies work alongside each other.

The overall experience hasn't just increased my confidence in application development but it fundamentally improved my confidence and understanding when conducting a penetration test as I can now more intuitively trace and exploit authentication flaws, and other application flaws.

This is a skill that I would like to further expand on in Django and to also further learn more methods of applying it in other web application frameworks.

Thank you for reading if there are any questions please feel free to reach out to me directly.

References


 

Django Rest Framework Documentation

Almabetter