VBV-76: Refactor user and login handling

This commit is contained in:
Daniel Egger 2022-06-28 14:43:20 +02:00
parent 4b02991f0d
commit 2af7439b97
25 changed files with 281 additions and 125 deletions

View File

@ -1,24 +1,21 @@
import type {NavigationGuardWithThis, RouteLocationNormalized} from 'vue-router'; import type {NavigationGuardWithThis, RouteLocationNormalized} from 'vue-router';
import type {UserState} from '@/stores/user' import {useUserStore} from '@/stores/user';
import {useUserStore} from '@/stores/user'
import type {Store} from 'pinia';
const cookieName = 'loginStatus'
let userStore: Store | null = null
export const updateLoggedIn: NavigationGuardWithThis<undefined> = (_to) => { export const updateLoggedIn: NavigationGuardWithThis<undefined> = (_to) => {
const loggedIn = getCookieValue(cookieName) === 'true' const loggedIn = getCookieValue('loginStatus') === 'true'
const store = getUserStore() const userStore = useUserStore()
store.$patch({ loggedIn }) userStore.$patch({loggedIn});
if (loggedIn && !userStore.email) {
userStore.fetchUser();
}
} }
export const redirectToLoginIfRequired: NavigationGuardWithThis<undefined> = (to, _from) => { export const redirectToLoginIfRequired: NavigationGuardWithThis<undefined> = (to, _from) => {
const store = getUserStore() const userStore = useUserStore()
if(loginRequired(to) && !store.loggedIn) { if(loginRequired(to) && !userStore.loggedIn) {
return { name: 'home' } return `/login?next=${to.fullPath}`
} }
} }
@ -31,20 +28,6 @@ export const getCookieValue = (cookieName: string): string => {
return cookieValue.pop() || ''; return cookieValue.pop() || '';
} }
// Pina is not ready when router is called the first time by app.use(), so we need to load it here
const getUserStore = (): UserState & Store => {
if (!userStore) {
userStore = useUserStore()
}
return userStore as unknown as UserState & Store
}
const loginRequired = (to: RouteLocationNormalized) => { const loginRequired = (to: RouteLocationNormalized) => {
return !to.meta?.public return !to.meta?.public
} }
// export const unauthorizedAccess: NavigationGuardWithThis<undefined> = (to) => {
// r loginRequired(to) && getCookieValue('loginStatus') !== 'true';
// }

View File

@ -1,9 +1,8 @@
import {createRouter, createWebHistory} from 'vue-router' import {createRouter, createWebHistory} from 'vue-router'
import HomeView from '../views/HomeView.vue'; import HomeView from '../views/HomeView.vue';
import LoginView from '../views/LoginView.vue';
import {redirectToLoginIfRequired, updateLoggedIn} from '@/router/guards'; import {redirectToLoginIfRequired, updateLoggedIn} from '@/router/guards';
const loginUrl = '/sso/login/'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: [ routes: [
@ -18,15 +17,12 @@ const router = createRouter({
}, },
{ {
path: '/login', path: '/login',
component: HomeView, component: LoginView,
beforeEnter(_to, _from) {
window.location.href = loginUrl
},
meta: { meta: {
public: true public: true
} }
}, },
{ {
path: '/learningpath/:learningPathSlug', path: '/learningpath/:learningPathSlug',
component: () => import('../views/LearningPathView.vue'), component: () => import('../views/LearningPathView.vue'),
props: true props: true

View File

@ -1,7 +1,7 @@
import type {CircleChild, LearningContent, LearningSequence, LearningUnit} from '@/types'; import type {CircleChild, LearningContent, LearningSequence, LearningUnit} from '@/types';
function createEmptyLearningUnit(parentLearningSequence: LearningSequence): LearningUnit { function _createEmptyLearningUnit(parentLearningSequence: LearningSequence): LearningUnit {
return { return {
id: 0, id: 0,
title: '', title: '',
@ -35,7 +35,7 @@ export function parseLearningSequences (children: CircleChild[]): LearningSequen
learningSequence = Object.assign(child, {learningUnits: []}); learningSequence = Object.assign(child, {learningUnits: []});
// initialize empty learning unit if there will not come a learning unit next // initialize empty learning unit if there will not come a learning unit next
learningUnit = createEmptyLearningUnit(learningSequence); learningUnit = _createEmptyLearningUnit(learningSequence);
} else if (child.type === 'learnpath.LearningUnit') { } else if (child.type === 'learnpath.LearningUnit') {
if (!learningSequence) { if (!learningSequence) {
throw new Error('LearningUnit found before LearningSequence'); throw new Error('LearningUnit found before LearningSequence');

View File

@ -1,8 +0,0 @@
import axios from 'axios';
export function getUserData () {
return axios({
method: 'get',
url: 'http://localhost:3000/api/me',
})
}

View File

@ -1,8 +1,15 @@
import * as log from 'loglevel';
import {defineStore} from 'pinia' import {defineStore} from 'pinia'
import {itGet, itPost} from '@/fetchHelpers';
// typed state https://stackoverflow.com/questions/71012513/when-using-pinia-and-typescript-how-do-you-use-an-action-to-set-the-state // typed state https://stackoverflow.com/questions/71012513/when-using-pinia-and-typescript-how-do-you-use-an-action-to-set-the-state
export type UserState = { export type UserState = {
first_name: string,
last_name: string,
email: string; email: string;
username: string,
avatar_url: string,
loggedIn: boolean; loggedIn: boolean;
} }
@ -10,16 +17,38 @@ export const useUserStore = defineStore({
id: 'user', id: 'user',
state: () => ({ state: () => ({
email: '', email: '',
first_name: '',
last_name: '',
username: '',
avatar_url: '',
loggedIn: false loggedIn: false
} as UserState), } as UserState),
getters: { getters: {
}, },
actions: { actions: {
setEmail (email: string) { handleLogin(username: string, password: string, next='/') {
this.email = email if (username && password) {
itPost('/core/login/', {
username,
password,
}).then((data) => {
this.$state = data;
this.loggedIn = true;
log.debug(`redirect to ${next}`);
window.location.href = next;
}).catch(() => {
this.loggedIn = false;
alert('Login failed');
});
}
}, },
setLoggedIn (isLoggedIn: boolean) { fetchUser() {
this.loggedIn = isLoggedIn itGet('/api/core/me/').then((data) => {
this.$state = data;
this.loggedIn = true;
}).catch(() => {
this.loggedIn = false;
})
} }
} }
}) })

View File

@ -11,6 +11,8 @@ import {useCircleStore} from '@/stores/circle';
import SelfEvaluation from '@/components/circle/SelfEvaluation.vue'; import SelfEvaluation from '@/components/circle/SelfEvaluation.vue';
import CircleDiagram from '@/components/circle/CircleDiagram.vue'; import CircleDiagram from '@/components/circle/CircleDiagram.vue';
log.debug('CircleView.vue created');
const props = defineProps<{ const props = defineProps<{
circleSlug: string circleSlug: string
}>() }>()

View File

@ -9,8 +9,9 @@ import MainNavigationBar from '@/components/MainNavigationBar.vue';</script>
<div class="mt-8 flex flex-col lg:flex-row justify-start gap-4"> <div class="mt-8 flex flex-col lg:flex-row justify-start gap-4">
<router-link class="link text-xl" to="/styleguide">Styelguide</router-link> <router-link class="link text-xl" to="/styleguide">Styelguide</router-link>
<a class="link text-xl" href="/login/">Login</a> <a class="link text-xl" href="/login">Login</a>
<router-link class="link text-xl" to="/learningpath/versicherungsvermittlerin">Lernpfad "Versicherungsvermittlerin" (Login benötigt)</router-link>
<router-link class="link text-xl" to="/learningpath/versicherungsvermittlerin">Lernpfad "Versicherungsvermittlerin" (Login benötigt)</router-link>
<router-link class="link text-xl" to="/circle/analyse">Circle "Analyse" (Login benötigt)</router-link> <router-link class="link text-xl" to="/circle/analyse">Circle "Analyse" (Login benötigt)</router-link>
</div> </div>
</main> </main>

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import * as log from 'loglevel';
import MainNavigationBar from '@/components/MainNavigationBar.vue';
import {reactive} from 'vue';
import {useUserStore} from '@/stores/user';
import {useRoute} from 'vue-router';
const route = useRoute()
log.debug('LoginView.vue created');
log.debug(route.query);
const state = reactive({
username: '',
password: '',
});
const userStore = useUserStore();
</script>
<template>
<MainNavigationBar/>
<main class="px-8 py-8">
<h1>Login</h1>
<form
@submit.prevent="userStore.handleLogin(state.username, state.password, route.query.next)"
>
<div class="mt-8 mb-4">
<label class="block mb-1" for="email">Username</label>
<input
id="username"
type="text"
name="username"
v-model="state.username"
class="py-2 px-3 border border-gray-500 mt-1 block w-96"
/>
</div>
<div class="mb-4">
<label class="block mb-1" for="password">Password</label>
<input
id="password"
type="password"
name="password"
v-model="state.password"
class="py-2 px-3 border border-gray-500 mt-1 block w-96"
/>
</div>
<div>
<input
type="submit"
value="Login"
class="btn-primary"
/>
</div>
</form>
</main>
</template>
<style scoped>
</style>

View File

@ -129,7 +129,7 @@ AUTH_USER_MODEL = "core.User"
# FIXME make configurable!? # FIXME make configurable!?
# LOGIN_URL = "/sso/login/" # LOGIN_URL = "/sso/login/"
LOGIN_URL = "/login/" LOGIN_URL = "/login"
LOGIN_REDIRECT_URL = "/" LOGIN_REDIRECT_URL = "/"
ALLOW_LOCAL_LOGIN = env.bool("IT_ALLOW_LOCAL_LOGIN", default=False) ALLOW_LOCAL_LOGIN = env.bool("IT_ALLOW_LOCAL_LOGIN", default=False)

View File

@ -1,7 +1,6 @@
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.contrib.auth.decorators import user_passes_test from django.contrib.auth.decorators import user_passes_test
from django.contrib.staticfiles.urls import staticfiles_urlpatterns from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.urls import include, path, re_path from django.urls import include, path, re_path
@ -17,7 +16,7 @@ from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt
from vbv_lernwelt.core.views import ( from vbv_lernwelt.core.views import (
rate_limit_exceeded_view, rate_limit_exceeded_view,
permission_denied_view, permission_denied_view,
check_rate_limit, cypress_reset_view, vue_home, ) check_rate_limit, cypress_reset_view, vue_home, vue_login, me_user_view, )
from .wagtail_api import wagtail_api_router from .wagtail_api import wagtail_api_router
@ -42,14 +41,16 @@ urlpatterns = [
path('pages/', include(wagtail_urls)), path('pages/', include(wagtail_urls)),
path('learnpath/', include("vbv_lernwelt.learnpath.urls")), path('learnpath/', include("vbv_lernwelt.learnpath.urls")),
path('api/completion/', include("vbv_lernwelt.completion.urls")), path('api/completion/', include("vbv_lernwelt.completion.urls")),
re_path(r'api/core/me/$', me_user_view, name='me_user_view'),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
if settings.DEBUG: if settings.DEBUG:
# Static file serving when using Gunicorn + Uvicorn for local web socket development # Static file serving when using Gunicorn + Uvicorn for local web socket development
urlpatterns += staticfiles_urlpatterns() urlpatterns += staticfiles_urlpatterns()
if settings.ALLOW_LOCAL_LOGIN: if settings.ALLOW_LOCAL_LOGIN:
urlpatterns += [path("login/", django_view_authentication_exempt( urlpatterns += [
auth_views.LoginView.as_view(template_name="core/login.html"))),] re_path(r'core/login/$', django_view_authentication_exempt(vue_login), name='vue_login'),
]
# API URLS # API URLS

View File

@ -19,7 +19,7 @@ class CompletionApiTestCase(APITestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.user = User.objects.get(username='student') self.user = User.objects.get(username='student')
self.client.login(username='student', password='student') self.client.login(username='student', password='test')
def test_completeLearningContent_works(self): def test_completeLearningContent_works(self):
learning_content = LearningContent.objects.get(title='Einleitung Circle "Anlayse"') learning_content = LearningContent.objects.get(title='Einleitung Circle "Anlayse"')

View File

@ -5,25 +5,75 @@ from vbv_lernwelt.core.models import User
def create_default_users(user_model=User, group_model=Group, default_password=None): def create_default_users(user_model=User, group_model=Group, default_password=None):
if default_password is None:
default_password = 'test'
admin_group, created = group_model.objects.get_or_create(name='admin_group') admin_group, created = group_model.objects.get_or_create(name='admin_group')
content_creator_grop, created = group_model.objects.get_or_create(name='content_creator_grop') content_creator_grop, created = group_model.objects.get_or_create(name='content_creator_grop')
student_group, created = group_model.objects.get_or_create(name='student_group') student_group, created = group_model.objects.get_or_create(name='student_group')
admin_password = default_password def _create_student_user(email, first_name, last_name, avatar_url=''):
if not admin_password: student_user, created = _get_or_create_user(
admin_password = 'admin' user_model=user_model, username=email, password=default_password,
admin_user, created = _get_or_create_user(user_model=user_model, username='admin', password=admin_password) )
admin_user.is_superuser = True student_user.first_name = first_name
admin_user.is_staff = True student_user.last_name = last_name
admin_user.groups.add(admin_group) student_user.avatar_url = avatar_url
admin_user.save() student_user.groups.add(student_group)
student_user.save()
student_user_password = default_password def _create_admin_user(email, first_name, last_name, avatar_url=''):
if not student_user_password: admin_user, created = _get_or_create_user(
student_user_password = 'student' user_model=user_model, username=email, password=default_password
student_user, created = _get_or_create_user(user_model=user_model, username='student', password=student_user_password) )
student_user.groups.add(student_group) admin_user.is_superuser = True
student_user.save() admin_user.is_staff = True
admin_user.first_name = first_name
admin_user.last_name = last_name
admin_user.groups.add(admin_group)
admin_user.save()
_create_admin_user(
email='info@iterativ.ch',
first_name='Info',
last_name='Iterativ',
avatar_url='/static/avatars/avatar_iterativ.png'
)
_create_admin_user(
email='admin',
first_name='Peter',
last_name='Adminson',
avatar_url='/static/avatars/avatar_iterativ.png'
)
_create_student_user(
email='student',
first_name='Student',
last_name='Meier',
avatar_url='/static/avatars/avatar_iterativ.png'
)
_create_student_user(
email='daniel.egger@iterativ.ch',
first_name='Daniel',
last_name='Egger',
avatar_url='/static/avatars/avatar_iterativ.png'
)
_create_student_user(
email='axel.manderbach@lernetz.ch',
first_name='Axel',
last_name='Manderbach',
avatar_url='/static/avatars/avatar_axel.png'
)
_create_student_user(
email='christoph.bosshard@vbv-afa.ch',
first_name='Christoph',
last_name='Bosshard',
avatar_url='/static/avatars/avatar_christoph.png'
)
def _get_or_create_user(user_model, *args, **kwargs): def _get_or_create_user(user_model, *args, **kwargs):
@ -34,6 +84,10 @@ def _get_or_create_user(user_model, *args, **kwargs):
user = user_model.objects.filter(username=username).first() user = user_model.objects.filter(username=username).first()
if not user: if not user:
user = user_model.objects.create(username=username, password=make_password(password)) user = user_model.objects.create(
username=username,
password=make_password(password),
email=username,
)
created = True created = True
return user, created return user, created

View File

@ -10,4 +10,4 @@ def command(customer_language):
print("cypress reset data") print("cypress reset data")
delete_default_learning_path() delete_default_learning_path()
create_default_learning_path() create_default_learning_path(skip_locales=False)

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.13 on 2022-06-28 12:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='user',
name='avatar_url',
field=models.CharField(blank=True, default='', max_length=254),
),
migrations.AlterField(
model_name='user',
name='email',
field=models.EmailField(max_length=254, unique=True, verbose_name='email address'),
),
]

View File

@ -15,7 +15,7 @@ def create_users(apps, schema_editor):
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("core", "0001_initial"), ("core", "0002_user_model"),
] ]
operations = [ operations = [

View File

@ -10,6 +10,8 @@ class User(AbstractUser):
""" """
# FIXME: look into it... # FIXME: look into it...
# objects = UserManager() # objects = UserManager()
avatar_url = models.CharField(max_length=254, blank=True, default='')
email = models.EmailField('email address', unique=True)
class SecurityRequestResponseLog(models.Model): class SecurityRequestResponseLog(models.Model):

View File

@ -0,0 +1,11 @@
from rest_framework import serializers
from vbv_lernwelt.core.models import User
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = [
'id', 'first_name', 'last_name', 'email', 'username', 'avatar_url',
]

View File

@ -1,35 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="container mx-auto">
<div class="w-full min-h-screen bg-gray-50 flex flex-col sm:justify-center items-center pt-6 sm:pt-0">
<div class="w-full sm:max-w-md p-5 mx-auto">
<h2 class="mb-12 text-center text-5xl font-extrabold">Login</h2>
<form method="POST">
{% csrf_token %}
<div class="mb-4">
<label class="block mb-1" for="email">Username</label>
<input id="username" type="text" name="username" class="py-2 px-3 border border-gray-300 focus:border-red-300 focus:outline-none focus:ring focus:ring-red-200 focus:ring-opacity-50 rounded-md shadow-sm disabled:bg-gray-100 mt-1 block w-full"/>
</div>
<div class="mb-4">
<label class="block mb-1" for="password">Password</label>
<input id="password" type="password" name="password" class="py-2 px-3 border border-gray-300 focus:border-red-300 focus:outline-none focus:ring focus:ring-red-200 focus:ring-opacity-50 rounded-md shadow-sm disabled:bg-gray-100 mt-1 block w-full"/>
</div>
<div class="mt-6">
<button
class="w-full inline-flex items-center justify-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold capitalize text-white hover:bg-red-700 active:bg-red-700 focus:outline-none focus:border-red-700 focus:ring focus:ring-red-200 disabled:opacity-25 transition"
data-cy="submit"
type="submit">
Login
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -6,8 +6,6 @@ from rest_framework.throttling import UserRateThrottle
from structlog.types import EventDict from structlog.types import EventDict
#from .models import User
def structlog_add_app_info( def structlog_add_app_info(
logger: logging.Logger, method_name: str, event_dict: EventDict logger: logging.Logger, method_name: str, event_dict: EventDict
) -> EventDict: ) -> EventDict:

View File

@ -1,7 +1,9 @@
# Create your views here. # Create your views here.
import requests import requests
import structlog
from django.conf import settings from django.conf import settings
from django.contrib.auth import authenticate, login
from django.core.management import call_command from django.core.management import call_command
from django.http import JsonResponse, HttpResponse, HttpResponseRedirect from django.http import JsonResponse, HttpResponse, HttpResponseRedirect
from django.shortcuts import render from django.shortcuts import render
@ -11,8 +13,12 @@ from ratelimit.decorators import ratelimit
from rest_framework import authentication from rest_framework import authentication
from rest_framework.decorators import api_view, authentication_classes, permission_classes from rest_framework.decorators import api_view, authentication_classes, permission_classes
from rest_framework.permissions import IsAdminUser from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response
from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt
from vbv_lernwelt.core.serializers import UserSerializer
logger = structlog.get_logger(__name__)
@django_view_authentication_exempt @django_view_authentication_exempt
@ -37,6 +43,32 @@ def vue_home(request):
return HttpResponse(content) return HttpResponse(content)
@api_view(['POST'])
@ensure_csrf_cookie
def vue_login(request):
try:
username = request.data.get('username')
password = request.data.get('password')
if username and password:
user = authenticate(request, username=username, password=password)
if user:
login(request, user)
logger.debug('login successful', username=username, email=user.email, label='login')
return Response(UserSerializer(user).data)
except Exception as e:
logger.exception(e)
logger.debug('login failed', username=username, label='login')
return Response({'success': False}, status=401)
@api_view(['GET'])
def me_user_view(request):
if request.user.is_authenticated:
return Response(UserSerializer(request.user).data)
return Response(status=403)
def permission_denied_view(request, exception): def permission_denied_view(request, exception):
return render(request, "403.html", status=403) return render(request, "403.html", status=403)

View File

@ -5,4 +5,4 @@ from vbv_lernwelt.learnpath.tests.create_default_learning_path import create_def
@click.command() @click.command()
def command(): def command():
create_default_learning_path() create_default_learning_path(skip_locales=False)

View File

@ -12,9 +12,9 @@ from vbv_lernwelt.learnpath.tests.learning_path_factories import LearningPathFac
ExerciseBlockFactory, DocumentBlockFactory, LearningUnitFactory, LearningUnitQuestionFactory ExerciseBlockFactory, DocumentBlockFactory, LearningUnitFactory, LearningUnitQuestionFactory
def create_default_learning_path(user=None): def create_default_learning_path(user=None, skip_locales=True):
if user is None: if user is None:
user = User.objects.get(username='admin') user = User.objects.get(username='info@iterativ.ch')
site = Site.objects.filter(is_default_site=True).first() site = Site.objects.filter(is_default_site=True).first()
@ -409,18 +409,19 @@ Fachspezialisten bei.
# circle_7 = CircleFactory.create(title="Prüfungsvorbereitung", parent=lp, topic=tp) # circle_7 = CircleFactory.create(title="Prüfungsvorbereitung", parent=lp, topic=tp)
# locales # locales
locale_de = Locale.objects.get(language_code='de-CH') if not skip_locales:
locale_fr, _ = Locale.objects.get_or_create(language_code='fr-CH') locale_de = Locale.objects.get(language_code='de-CH')
LocaleSynchronization.objects.get_or_create( locale_fr, _ = Locale.objects.get_or_create(language_code='fr-CH')
locale_id=locale_fr.id, LocaleSynchronization.objects.get_or_create(
sync_from_id=locale_de.id locale_id=locale_fr.id,
) sync_from_id=locale_de.id
locale_it, _ = Locale.objects.get_or_create(language_code='it-CH') )
LocaleSynchronization.objects.get_or_create( locale_it, _ = Locale.objects.get_or_create(language_code='it-CH')
locale_id=locale_it.id, LocaleSynchronization.objects.get_or_create(
sync_from_id=locale_de.id locale_id=locale_it.id,
) sync_from_id=locale_de.id
call_command('sync_locale_trees') )
call_command('sync_locale_trees')
# all pages belong to 'admin' by default # all pages belong to 'admin' by default
Page.objects.update(owner=user) Page.objects.update(owner=user)

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB