diff --git a/pairent_backend/pairent_app/authlib.py b/pairent_backend/pairent_app/authlib.py new file mode 100644 index 0000000..3026648 --- /dev/null +++ b/pairent_backend/pairent_app/authlib.py @@ -0,0 +1,87 @@ +from django.http import HttpResponseBadRequest, HttpResponse, JsonResponse, HttpRequest + +def VVSUAuthProxy(req: HttpRequest): + proxy = 'https://vvsu.ru/connect' + req.path[len('/api/auth/vvsu'):]; + + preq = requests.request(req.method, proxy, headers={ + 'User-Agent': 'OIDC Client / Pairent', + 'Origin': 'http://pairent.vvsu.ru', + 'Referer': 'http://pairent.vvsu.ru' + }); + + resp = HttpResponse(preq.content); + resp.headers['Content-Type'] = preq.headers['Content-Type']; + + return resp; + +def register(oid, provider_id, name): + user = User( + favorites_apartments='', + comparison_apartments='', + name=name, + # date_of_birth=, + about_me='', + gender='?', + phone='+00000', + # email=, + # telegram=, + # discord=, + # city=, + role='s', + photo_provider='VVSU', + openid_addr=oid, + openid_id=provider_id, + ); + user.save(); + return user; + +def get_oauth_token(remote, data): + return requests.post(remote + '/oauth2/token', data, + headers={ + 'Origin': 'https://pairent.vvsu.ru', + 'Referer': 'https://pairent.vvsu.ru' + }).json(); + +def get_oauth_data(remote, key): + return requests.get(remote + '/userinfo', headers={ + 'Origin': 'https://pairent.vvsu.ru', + 'Authorization': 'Bearer ' + key, + 'User-Agent': 'curl/8.1' + }).json(); + +def create_auth_token(userid, ip): + + try: + token = AuthToken.objects.get(user=userid, ip=ip); + if (verify_auth_token(token.key, token.ip)): + return token; + except AuthToken.DoesNotExist: + 0 # ignore + + token = AuthToken( + user=userid, + key=str(uuid.uuid4()), + # 2 days + # vvv + expires=time.time() + 60 * 60 * 24 * 2, + ip=ip + ); + token.save(); + return token; + +def verify_auth_token(key, ip): + + try: + token = AuthToken.objects.get(key=key); + except AuthToken.DoesNotExist: + return False; + + if (token.ip != ip): + token.delete(); + return False; + + if (token.expires > time.time()): + token.delete(); + return False; + + return True; diff --git a/pairent_backend/pairent_app/migrations/0005_user.py b/pairent_backend/pairent_app/migrations/0005_user.py index f3951ef..43a57dd 100644 --- a/pairent_backend/pairent_app/migrations/0005_user.py +++ b/pairent_backend/pairent_app/migrations/0005_user.py @@ -17,22 +17,31 @@ class Migration(migrations.Migration): name='User', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('favorites_apartments', models.CharField(max_length=100, help_text="Избранные квартиры (CSV)")), - ('comparison_apartments', models.CharField(max_length=100, help_text="Квартиры для сравнения (CSV)")), - ('name', models.CharField(max_length=256, help_text='ФИО Пользователя')), - ('date_of_birth', models.DateField(help_text='Дата рождения пользователя')), - ('about_me', models.CharField(max_length=1000, help_text='Поле "О Себе"')), - ('gender', models.CharField(max_length=1, help_text='Пол пользователя (f,m,n,?)')), + ('favorites_apartments', models.CharField(max_length=100, help_text="Избранные квартиры (CSV)", null=True)), + ('comparison_apartments', models.CharField(max_length=100, help_text="Квартиры для сравнения (CSV)", null=True)), + ('name', models.CharField(max_length=500, help_text='ФИО Пользователя', null=True)), + ('date_of_birth', models.DateField(help_text='Дата рождения пользователя', null=True)), + ('about_me', models.CharField(max_length=1000, help_text='Поле "О Себе"', null=True)), + ('gender', models.CharField(max_length=1, help_text='Пол пользователя (f,m,n,?)', null=True)), ('phone', models.CharField(max_length=30, help_text='Телефон пользователя в международном формате (+00000000)', null=True)), ('email', models.CharField(max_length=1000, help_text='Почтовый ящик пользователя в формате user@example.com', null=True)), ('telegram', models.CharField(max_length=1000, help_text='Телеграм пользователя', null=True)), ('discord', models.CharField(max_length=1000, help_text='Дискорд ник пользователя', null=True)), ('city', models.CharField(max_length=1000, help_text='Город пользователя', null=True)), ('role', models.CharField(max_length=1, help_text='Роль пользователя (s - student, a - admin, m - moderator)', null=False)), - ('photo_provider', models.CharField(max_length=100, verbose_name='Сервис, из которого загружается фотография пользователя (VVSU, GRAVATAR)')), + ('photo_provider', models.CharField(max_length=100, verbose_name='Сервис, из которого загружается фотография пользователя (VVSU, GRAVATAR)', null=True)), ('openid_addr', models.CharField(max_length=1000, null=False, help_text='Адрес Open ID Connect (login@provider.com, для ВВГУ - login@vvsu.ru)')), - ('openid_id', models.CharField(max_length=5000, verbose_name='ID Пользователя в системе провайдера авторизации (скорее всего ВВГУ)')) + ('openid_id', models.CharField(max_length=5000, null=False, verbose_name='ID Пользователя в системе провайдера авторизации (скорее всего ВВГУ)')) + ] + ), + migrations.CreateModel( + name='AuthToken', + fields=[ + ('user', models.BigIntegerField(null=False, verbose_name='ID Пользователя, которому принадлежит токен')), + ('key', models.TextField(verbose_name='Ключ API')), + ('expires', models.BigIntegerField(verbose_name='Когда ключ истечет (Unix timestamp)')), + ('ip', models.CharField(max_length=16, verbose_name='IP, с которого был создан ключ')) ] ) ] diff --git a/pairent_backend/pairent_app/models.py b/pairent_backend/pairent_app/models.py index 68fff93..b03e5d8 100644 --- a/pairent_backend/pairent_app/models.py +++ b/pairent_backend/pairent_app/models.py @@ -129,4 +129,10 @@ class PsychTestAnswers(models.Model): class Meta: verbose_name = "Ответ на психологический тест" - verbose_name_plural = "Ответы на психологический тест" \ No newline at end of file + verbose_name_plural = "Ответы на психологический тест" + +class AuthToken(models.Model): + user = models.BigIntegerField(null=False, verbose_name='ID Пользователя, которому принадлежит токен'); + key = models.TextField(verbose_name='Ключ API'); + expires = models.BigIntegerField(verbose_name='Когда ключ истечет (Unix timestamp)'); + ip = models.CharField(max_length=16, verbose_name='IP, с которого был создан ключ'); diff --git a/pairent_backend/pairent_app/serializer.py b/pairent_backend/pairent_app/serializer.py index 5df50cd..0102180 100644 --- a/pairent_backend/pairent_app/serializer.py +++ b/pairent_backend/pairent_app/serializer.py @@ -1,6 +1,10 @@ from rest_framework import serializers +<<<<<<< HEAD from .models import Apartament, User, PsychTestAnswers +======= +from .models import Apartament, User, AuthToken +>>>>>>> 4f5f817d835f6a53448e7d60bb7591c24c825e96 class ApartamentListSerializer(serializers.ModelSerializer): @@ -36,4 +40,8 @@ class PsychTestReultsSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = "__all__" \ No newline at end of file + fields = "__all__" +class TokenSerializer(serializers.ModelSerializer): + class Meta: + model = AuthToken + fields = '__all__' diff --git a/pairent_backend/pairent_app/views.py b/pairent_backend/pairent_app/views.py index 0a2baed..a0e10eb 100644 --- a/pairent_backend/pairent_app/views.py +++ b/pairent_backend/pairent_app/views.py @@ -10,15 +10,19 @@ from django.db.models.query import QuerySet from django.core.validators import validate_email from django.core.exceptions import ValidationError -from .models import Apartament, User, PsychTestAnswers +from .models import Apartament, User, PsychTestAnswers, AuthToken from .serializer import (ApartamentListSerializer, ApartamentDetailSerializer, PsychTestAddResultSerializer, PublicUserSerializer, PsychTestReultsSerializer, - UserSerializer) + UserSerializer, + TokenSerializer) -import json, math, random, re, requests, oidc_client, base64, hashlib +from .authlib import * + +import json, math, random, re, requests, oidc_client, base64, uuid, time, ipware as iplib +ipware = iplib.IpWare(); class ApartamentViewSet(viewsets.ReadOnlyModelViewSet): """Вывод списка квартир или отдельной квартиры""" @@ -163,58 +167,15 @@ class CompatibleUsersView(viewsets.ViewSet): return Response(users); -def VVSUAuthProxy(req: Request): - proxy = 'https://vvsu.ru/connect' + req.path[len('/api/auth/vvsu'):]; - - preq = requests.request(req.method, proxy, headers={ - 'User-Agent': 'OIDC Client / Pairent', - 'Origin': 'http://pairent.vvsu.ru', - 'Referer': 'http://pairent.vvsu.ru' - }); - - resp = HttpResponse(preq.content); - resp.headers['Content-Type'] = preq.headers['Content-Type']; - - return resp; - -def regiserUser(oid, provider_id, name, date_of_birth): - user = User( - favorites_apartments='', - comparison_apartments='', - name=name, - date_of_birth=date_of_birth, - about_me='', - gender='?', - phone='+00000', - # email=, - # telegram=, - # discord=, - # city=, - role='s', - # photo_provider=, - openid_addr=oid, - openid_id=provider_id, - ) - -def get_oauth_token(remote, data): - return requests.post(remote + '/oauth2/token', data, - headers={ - 'Origin': 'https://pairent.vvsu.ru', - 'Referer': 'https://pairent.vvsu.ru' - }).json(); - -def get_oauth_data(remote, key): - return requests.get(remote + '/userinfo', headers={ - 'Origin': 'https://pairent.vvsu.ru', - 'Authorization': 'Bearer ' + key, - 'User-Agent': 'curl/8.1' - }).json(); - class UserLogin(APIView): + # TODO: Remove csrf exempt when index.html is loaded through django @csrf_exempt def post(self, req: HttpRequest): + # for debug purposes + # return HttpResponse("""{"user_data": {"id": 1, "name": "\u041d\u0438\u043a\u0438\u0442\u0430 \u041f\u0443\u0441\u0442\u043e\u0432\u0430\u043b\u043e\u0432", "date_of_birth": null, "about_me": "", "gender": "?", "phone": "+00000", "email": null, "telegram": null, "discord": null, "city": null, "role": "s", "photo_provider": "VVSU", "openid_addr": "blek__@vvsu.ru", "openid_id": "096C78CD-4943-4D57-BC6D-5CDE12F686E3"}, "new_user": false, "token": {"id": 2, "user": 1, "key": "e1c24581-523a-4f60-973f-02ba873b3edc", "expires": 1684423572, "ip": "127.0.0.1"}}"""); + if (req.session.has_key('auth_data')): # TODO: Return user object instead of error return JsonResponse({'error': 'already authenticated'}) @@ -231,42 +192,44 @@ class UserLogin(APIView): res.status_code = 400; return res; - # auth_data = get_oauth_token('https://vvsu.ru/connect', { - # 'grant_type': 'authorization_code', - # 'redirect_uri': 'https://pairent.vvsu.ru/sign-in/', - # 'code': data['code'], - # 'code_verifier': data['code_verifier'], - # 'client_id': 'it-hub-client', - # 'client_secret': 'U8y@uPVee6Q^*729esHTo4Vd' - # }); + auth_data = get_oauth_token('https://vvsu.ru/connect', { + 'grant_type': 'authorization_code', + 'redirect_uri': 'https://pairent.vvsu.ru/sign-in/', + 'code': data['code'], + 'code_verifier': data['code_verifier'], + 'client_id': 'it-hub-client', + 'client_secret': 'U8y@uPVee6Q^*729esHTo4Vd' + }); - auth_data = {'access_token': '5kHvrjy91LJgJLKitejBBG24c7JiX45tEstKVHRpfHc._WQDwQ2F13aytbGFjlGnjXJeUWcDD1V3om3cRW0IujM', 'expires_in': 3600, 'id_token': 'eyJhbGciOiJSUzI1NiIsImtpZCI6InB1YmxpYzpoeWRyYS5vcGVuaWQuaWQtdG9rZW4iLCJ0eXAiOiJKV1QifQ.eyJhY3IiOiIwIiwiYXRfaGFzaCI6IjRMR1dRekxVaXFodUVTYjU0QWFIM0EiLCJhdWQiOlsiaXQtaHViLWNsaWVudCJdLCJhdXRoX3RpbWUiOjE2ODQyMzc4MDksImNhbGxiYWNrX3VybCI6IiIsImV4cCI6MTY4NDI0MTQ1NSwiZmFtaWx5X25hbWUiOiLQn9GD0YHRgtC-0LLQsNC70L7QsiIsImdpdmVuX25hbWUiOiLQndC40LrQuNGC0LAiLCJpYXQiOjE2ODQyMzc4NTUsImlkIjoiMDk2Qzc4Q0QtNDk0My00RDU3LUJDNkQtNUNERTEyRjY4NkUzIiwiaXNzIjoiaHR0cHM6Ly93d3cudnZzdS5ydS9jb25uZWN0LyIsImp0aSI6IjEzMTBhNzcwLWFhZWUtNGExYS1hMTc1LWM3MzY3ZWM0ZjVhNyIsImxvZ2luIjoiaHR0cHM6Ly9vcGVuaWQudnZzdS5ydS9ibGVrX18iLCJvcGVuaWQiOiJodHRwczovL29wZW5pZC52dnN1LnJ1L2JsZWtfXyIsInBpY3R1cmUiOiJodHRwczovL3d3dy52dnN1LnJ1L29pc2twL3Bob3RvL3B0aC5hc3A_SUQ9MDk2Qzc4Q0QtNDk0My00RDU3LUJDNkQtNUNERTEyRjY4NkUzXHUwMDI2IiwicHJvZmlsZV91cmwiOm51bGwsInJhdCI6MTY4NDIzNzc5Nywic2lkIjoiOTYyYzg0OGYtZThkNS00ZDJjLWEwZmEtYjI5YmU3YjBlODAxIiwic3ViIjoiaHR0cHM6Ly9vcGVuaWQudnZzdS5ydS9ibGVrX18iLCJzdXJuYW1lIjoi0J_Rg9GB0YLQvtCy0LDQu9C-0LIiLCJ0aXRsZSI6ItCh0YLRg9C00LXQvdGCIiwidnZzdV9JZEVtcGwiOm51bGwsInZ2c3VfSWRTdHVkIjoiMTk3MDgwIiwidnZzdV9JZFVzZXIiOjE5MDQ4OSwidnZzdV9sb2dpbiI6ImJsZWtfXyJ9.A4BiOxpOqnesSiTGRdcTsC-lGhSABswivpUovD9EOdYmqKW753VlLcXQxfBPcfmq8Fdf7RmVvXTXPXYqkX7AKxQT-yUUm7XtJHCb85g2YfL64cjTP2sFYD6wPIU9nzXbCrsgKqKubY3p16Dn9VyrBCXE9N6jdbuNOFbWMLPLPlp7U5fx2SzVGaBMUONlTf8KiLkcisQoN4c_rPGqdi38gzhLf7WGEiKLOldXH1q-s_kPeObFvcdbsFrrnDPnJtdqBx8SF02wqJsrZlBiB9Hl-d6sSJYLZZWumFhS-qscfwRlTEZKqC-hWF5c9R8CUYewk89JxRvCcKrHZvPMip9j9vJF1_OjkSrC5EkGaprl765FgVPEBJqXj9LjGRkTOYfYUFAAMia_HhjtinQFp6XJ-Rh3JrmIfLAQ7DEUSOldMQ1xUw9GeHo_0sIsnjaM6lVx6M_SiDTWihxNu58DiI8tmvkdw7in95OJRoJZ30EhR3SGYsK3b51qdYK1aieufJHX40bN_S1gc84pisTg58z-zC5kGsjsZNv6gRSTO4oOpZMK1FMjv7HyasSMWEu-J052X4Qxquj4pWglpiGQNt3-E0jZUUjqmZ0-7AYiyEC_3IItBqWrve-LTXRF5faIZB5v3F3urY6Qjgn93m_AoK1oujfNAPk8WOLTv419CuC2fAc', 'scope': 'openid vvsu_IdUser vvsu_IdEmpl vvsu_IdStud vvsu_login given_name family_name', 'token_type': 'bearer'} + if ('error' in auth_data): + return JsonResponse(auth_data); user = None; new_user = False; - print(auth_data); - - return JsonResponse(get_oauth_data('https://vvsu.ru/connect', auth_data['access_token'])); - - req.session['auth_data'] = vvsu_data; + vvsu_data = get_oauth_data('https://vvsu.ru/connect', auth_data['access_token']); + + if ('error' in vvsu_data): + res = JsonResponse(vvsu_data); + res.status_code = 500; + return res; if ('error' in vvsu_data): res = JsonResponse(vvsu_data); - res.status_code = cb.status_code; + res.status_code = 500; return res vvsu_data['vvsu_login'] += '@vvsu.ru'; try: user = User.objects.get(openid_addr=vvsu_data['vvsu_login']); except User.DoesNotExist: - registerUser(vvsu_data['vvsu_login'], cb.id, f'{cb.given_name} {cb.family_name}'); - user = User.objects.get(openid_addr=vvsu_data['vvsu_login']); + user = register(vvsu_data['vvsu_login'], vvsu_data['id'], f"{vvsu_data['given_name']} {vvsu_data['family_name']}"); new_user = True; return JsonResponse({ - 'user_data': user, - 'new_user': new_user + 'user_data': PublicUserSerializer(user).data, + 'new_user': new_user, + 'token': TokenSerializer(create_auth_token(user.id, ipware.get_client_ip(req.META)[0].exploded)).data }); class UserGet(APIView): diff --git a/pairent_backend/requirements.txt b/pairent_backend/requirements.txt index d495564..c8a891d 100644 --- a/pairent_backend/requirements.txt +++ b/pairent_backend/requirements.txt @@ -4,4 +4,4 @@ djangorestframework django-cors-headers Pillow requests -oidc-client \ No newline at end of file +python-ipware \ No newline at end of file diff --git a/pairent_frontend_react/src/API/IAPIObject.js b/pairent_frontend_react/src/API/IAPIObject.js new file mode 100644 index 0000000..e4a1af8 --- /dev/null +++ b/pairent_frontend_react/src/API/IAPIObject.js @@ -0,0 +1,57 @@ + +/** + * Basic API interaction & local caching interface + */ +class IAPIObject { + /** + * Local storage key used to save data. + * @type {string} + */ + static storage_key = undefined; + + static _checkStorageSupport() { + if (this.storage_key === undefined) { + throw Error('This doesn\'t support local storage'); + } + } + + _getStorageKey() { + return Object.getPrototypeOf(this).constructor.storage_key; + } + + _IcheckStorageSupport() { + if (this._getStorageKey() === undefined) { + throw Error('This doesn\'t support local storage'); + } + } + + /** @returns {ThisType} */ + static restoreFromLocalStorage() { + _checkStorageSupport(); + if (!window.sessionStorage.getItem(this.storage_key)) + return false; + return new APIToken(window.sessionStorage.getItem(this.storage_key)); + } + + /** @returns {boolean} */ + static isCached() { + this._checkStorageSupport(); + if (window.sessionStorage.getItem(this.storage_key)) { + return true; + } + return false; + } + + /** + * Save this object to local storage + * @throws {QuotaExceededError} + * @returns {void} + */ + saveToLocalStorage() { + this._IcheckStorageSupport(); + + window.sessionStorage.setItem(this._getStorageKey(), JSON.stringify(this)); + } +} + +export { IAPIObject }; \ No newline at end of file diff --git a/pairent_frontend_react/src/API/User.js b/pairent_frontend_react/src/API/User.js index 4be7f14..99b84f7 100644 --- a/pairent_frontend_react/src/API/User.js +++ b/pairent_frontend_react/src/API/User.js @@ -1,6 +1,8 @@ import axios from 'axios'; import constants from '../constants'; +import { IAPIObject } from './IAPIObject'; + const { API_ROOT, api_path } = constants; class UserLoginResponse { @@ -14,8 +16,43 @@ class UserLoginResponse { id; } -class User { +class APIToken extends IAPIObject { + + static storage_key = 'pairent_api_key'; + constructor(data) { + super(); + this.user = data.user; + this.key = data.key; + this.expires = data.expires; + this.ip = data.ip; + } + + /** @type {number} */ + user; + + /** @type {string} */ + key; + + /** A Unix timestamp (when the token will expire) + * @type {number} + */ + expires; + + /** @type {string} */ + ip; +} + +class User extends IAPIObject { + + isLoggedIn() { + return false; + } + + static storage_key = 'pairent_user_data'; + + constructor(data) { + super(); for (const key in data) { this[key] = data[key]; } @@ -44,7 +81,16 @@ class User { } const data = await axios.post(api_path('/api/auth/user/login'), response); - return new User(data.data); + if (data.status !== 200) { + return false; + } + + if (!data.data.error) { + new APIToken(data.data.token).saveToLocalStorage(); + new User(data.data.user_data).saveToLocalStorage(); + } + + return data.data; } } diff --git a/pairent_frontend_react/src/pages/LoggedIn/index.jsx b/pairent_frontend_react/src/pages/LoggedIn/index.jsx index 476983b..141161c 100644 --- a/pairent_frontend_react/src/pages/LoggedIn/index.jsx +++ b/pairent_frontend_react/src/pages/LoggedIn/index.jsx @@ -32,12 +32,12 @@ export default class LoggedIn extends React.Component { constructor(props) { super(props); this.response = new SigninResponse(new URL(window.location.href).searchParams); - } async componentDidMount() { if (this.response.error) return; - + window.localStorage.removeItem('auth_fail'); + let code_verifier = '?'; // get code verifier for (const key in localStorage) { @@ -48,7 +48,21 @@ export default class LoggedIn extends React.Component { } } - console.log(await User.login({...this.response, code_verifier})); + const response = await User.login({...this.response, code_verifier}); + + if (response.error) { + // pass the data to LoginPage + window.localStorage.setItem('auth_fail', JSON.stringify(response)); + window.location.href = '/login'; + return; + } + + if (response.new_user) { + // TODO: Make the page + window.location.href = '/register'; + } else { + window.location.href = '/'; + } } render() { diff --git a/pairent_frontend_react/src/pages/LoginPage/index.jsx b/pairent_frontend_react/src/pages/LoginPage/index.jsx index 40b5ba4..f565b60 100644 --- a/pairent_frontend_react/src/pages/LoginPage/index.jsx +++ b/pairent_frontend_react/src/pages/LoginPage/index.jsx @@ -8,6 +8,8 @@ import FloatingBox from '../../components/UI/FloatingBox'; import * as OpenID from 'oidc-client-ts'; import constants from '../../constants'; +import { User } from '../../API/User'; + const { OIDCConfig } = constants; const LoginButton = styled(BlueButton)` @@ -45,9 +47,11 @@ export default class LoginPage extends React.Component { super(props); this.state = { - loading: false + loading: false, + error: JSON.parse(window.localStorage.getItem('auth_fail')) } + window.localStorage.removeItem('auth_fail'); this.openid = this.openid.bind(this); } @@ -56,15 +60,18 @@ export default class LoginPage extends React.Component { this.setState({loading: true}); OpenID.Log.setLogger(console); - OpenID.Log.setLevel(OpenID.Log.DEBUG); + OpenID.Log.setLevel(OpenID.Log.NONE); let client = new OpenID.UserManager(OIDCConfig); client.signinRedirect(); - } render() { + + if (User.isCached()) + window.location.href = '/'; + return (
@@ -84,6 +91,21 @@ export default class LoginPage extends React.Component { Вход осуществляется только через
Систему Единого Входа ВВГУ + { + this.state.error ? + + Произошла ошибка: { this.state.error.error } + { + this.state.error.error_description ? + <> +
+ {this.state.error.error_description} + + : null + } +
+ : null + }
);