1
0
mirror of https://github.com/etesync/server synced 2024-12-29 10:48:07 +00:00
etesync-server/etebase_server/fastapi/routers/authentication.py

265 lines
9.2 KiB
Python
Raw Normal View History

2020-12-23 21:29:08 +00:00
import typing as t
from datetime import datetime
import nacl
import nacl.encoding
import nacl.hash
import nacl.secret
import nacl.signing
from django.conf import settings
2024-06-08 21:51:44 +00:00
from django.contrib.auth import user_logged_in, user_logged_out
from django.core import exceptions as django_exceptions
2020-12-25 10:36:06 +00:00
from django.db import transaction
from django.utils.functional import cached_property
2024-06-08 21:51:44 +00:00
from fastapi import APIRouter, Depends, Request, status
from typing_extensions import Literal
2020-12-23 21:29:08 +00:00
from etebase_server.django import app_settings, models
from etebase_server.django.models import UserInfo
from etebase_server.django.signals import user_signed_up
2024-06-08 21:51:44 +00:00
from etebase_server.django.token_auth.models import AuthToken
from etebase_server.django.utils import CallbackContext, create_user, get_user_queryset
from etebase_server.myauth.models import UserType, get_typed_user_model
2024-06-08 21:51:44 +00:00
from ..dependencies import AuthData, get_auth_data, get_authenticated_user
2024-06-08 21:51:44 +00:00
from ..exceptions import AuthenticationFailed, HttpError, transform_validation_error
from ..msgpack import MsgpackRoute
from ..utils import BaseModel, get_user_username_email_kwargs, msgpack_decode, msgpack_encode, permission_responses
2020-12-23 21:29:08 +00:00
2020-12-29 11:22:36 +00:00
User = get_typed_user_model()
2020-12-28 07:35:27 +00:00
authentication_router = APIRouter(route_class=MsgpackRoute)
2020-12-23 21:29:08 +00:00
class LoginChallengeIn(BaseModel):
2020-12-23 21:29:08 +00:00
username: str
class LoginChallengeOut(BaseModel):
salt: bytes
challenge: bytes
version: int
2020-12-23 21:29:08 +00:00
class LoginResponse(BaseModel):
username: str
challenge: bytes
host: str
action: Literal["login", "changePassword"]
2020-12-23 21:29:08 +00:00
class UserOut(BaseModel):
username: str
email: str
pubkey: bytes
encryptedContent: bytes
@classmethod
2020-12-29 11:22:36 +00:00
def from_orm(cls: t.Type["UserOut"], obj: UserType) -> "UserOut":
return cls(
username=obj.username,
email=obj.email,
pubkey=bytes(obj.userinfo.pubkey),
encryptedContent=bytes(obj.userinfo.encryptedContent),
)
class LoginOut(BaseModel):
token: str
user: UserOut
@classmethod
2020-12-29 11:22:36 +00:00
def from_orm(cls: t.Type["LoginOut"], obj: UserType) -> "LoginOut":
token = AuthToken.objects.create(user=obj).key
user = UserOut.from_orm(obj)
return cls(token=token, user=user)
2020-12-23 21:29:08 +00:00
class Authentication(BaseModel):
2020-12-25 09:12:22 +00:00
class Config:
2024-06-08 22:10:34 +00:00
ignored_types= (cached_property,)
2020-12-25 09:12:22 +00:00
2020-12-23 21:29:08 +00:00
response: bytes
signature: bytes
class Login(Authentication):
@cached_property
def response_data(self) -> LoginResponse:
return LoginResponse(**msgpack_decode(self.response))
class ChangePasswordResponse(LoginResponse):
loginPubkey: bytes
encryptedContent: bytes
class ChangePassword(Authentication):
@cached_property
def response_data(self) -> ChangePasswordResponse:
return ChangePasswordResponse(**msgpack_decode(self.response))
2020-12-25 10:36:06 +00:00
class UserSignup(BaseModel):
username: str
email: str
class SignupIn(BaseModel):
user: UserSignup
salt: bytes
loginPubkey: bytes
pubkey: bytes
encryptedContent: bytes
def get_login_user(request: Request, challenge: LoginChallengeIn) -> UserType:
username = challenge.username
kwargs = get_user_username_email_kwargs(username)
2020-12-23 21:29:08 +00:00
try:
user_queryset = get_user_queryset(User.objects.all(), CallbackContext(request.path_params))
user = user_queryset.get(**kwargs)
2020-12-23 21:29:08 +00:00
if not hasattr(user, "userinfo"):
raise AuthenticationFailed(code="user_not_init", detail="User not properly init")
return user
except User.DoesNotExist:
raise AuthenticationFailed(code="user_not_found", detail="User not found")
2021-01-04 09:56:17 +00:00
def get_encryption_key(salt: bytes):
2020-12-23 21:29:08 +00:00
key = nacl.hash.blake2b(settings.SECRET_KEY.encode(), encoder=nacl.encoding.RawEncoder)
return nacl.hash.blake2b(
b"",
key=key,
salt=salt[: nacl.hash.BLAKE2B_SALTBYTES],
person=b"etebase-auth",
encoder=nacl.encoding.RawEncoder,
)
2020-12-29 11:22:36 +00:00
def save_changed_password(data: ChangePassword, user: UserType):
2020-12-23 21:29:08 +00:00
response_data = data.response_data
user_info: UserInfo = user.userinfo
user_info.loginPubkey = response_data.loginPubkey
user_info.encryptedContent = response_data.encryptedContent
user_info.save()
def validate_login_request(
validated_data: LoginResponse,
challenge_sent_to_user: Authentication,
2020-12-29 11:22:36 +00:00
user: UserType,
2020-12-23 21:29:08 +00:00
expected_action: str,
host_from_request: str,
):
2020-12-23 21:29:08 +00:00
enc_key = get_encryption_key(bytes(user.userinfo.salt))
box = nacl.secret.SecretBox(enc_key)
challenge_data = msgpack_decode(box.decrypt(validated_data.challenge))
now = int(datetime.now().timestamp())
if validated_data.action != expected_action:
2020-12-29 11:22:36 +00:00
raise HttpError("wrong_action", f'Expected "{expected_action}" but got something else')
2020-12-23 21:29:08 +00:00
elif now - challenge_data["timestamp"] > app_settings.CHALLENGE_VALID_SECONDS:
2020-12-28 07:51:27 +00:00
raise HttpError("challenge_expired", "Login challenge has expired")
2020-12-23 21:29:08 +00:00
elif challenge_data["userId"] != user.id:
2020-12-28 07:51:27 +00:00
raise HttpError("wrong_user", "This challenge is for the wrong user")
elif not settings.DEBUG and validated_data.host.split(":", 1)[0] != host_from_request.split(":", 1)[0]:
2020-12-28 07:51:27 +00:00
raise HttpError(
"wrong_host", f'Found wrong host name. Got: "{validated_data.host}" expected: "{host_from_request}"'
)
2020-12-23 21:29:08 +00:00
verify_key = nacl.signing.VerifyKey(bytes(user.userinfo.loginPubkey), encoder=nacl.encoding.RawEncoder)
try:
verify_key.verify(challenge_sent_to_user.response, challenge_sent_to_user.signature)
except nacl.exceptions.BadSignatureError:
2020-12-28 07:51:27 +00:00
raise HttpError("login_bad_signature", "Wrong password for user.", status.HTTP_401_UNAUTHORIZED)
2020-12-23 21:29:08 +00:00
2020-12-27 15:30:17 +00:00
@authentication_router.get("/is_etebase/")
async def is_etebase():
pass
2020-12-27 15:30:17 +00:00
@authentication_router.post("/login_challenge/", response_model=LoginChallengeOut)
2020-12-29 11:22:36 +00:00
def login_challenge(user: UserType = Depends(get_login_user)):
salt = bytes(user.userinfo.salt)
enc_key = get_encryption_key(salt)
2020-12-23 21:29:08 +00:00
box = nacl.secret.SecretBox(enc_key)
challenge_data = {
"timestamp": int(datetime.now().timestamp()),
"userId": user.id,
}
challenge = bytes(box.encrypt(msgpack_encode(challenge_data), encoder=nacl.encoding.RawEncoder))
return LoginChallengeOut(salt=salt, challenge=challenge, version=user.userinfo.version)
2020-12-23 21:29:08 +00:00
@authentication_router.post("/login/", response_model=LoginOut)
def login(data: Login, request: Request):
user = get_login_user(request, LoginChallengeIn(username=data.response_data.username))
2020-12-23 21:29:08 +00:00
host = request.headers.get("Host")
validate_login_request(data.response_data, data, user, "login", host)
ret = LoginOut.from_orm(user)
user_logged_in.send(sender=user.__class__, request=None, user=user)
return ret
2020-12-23 21:29:08 +00:00
2020-12-28 08:00:35 +00:00
@authentication_router.post("/logout/", status_code=status.HTTP_204_NO_CONTENT, responses=permission_responses)
2020-12-28 13:27:29 +00:00
def logout(auth_data: AuthData = Depends(get_auth_data)):
2020-12-28 13:17:13 +00:00
auth_data.token.delete()
user_logged_out.send(sender=auth_data.user.__class__, request=None, user=auth_data.user)
2020-12-23 21:29:08 +00:00
2020-12-28 08:00:35 +00:00
@authentication_router.post("/change_password/", status_code=status.HTTP_204_NO_CONTENT, responses=permission_responses)
def change_password(data: ChangePassword, request: Request, user: UserType = Depends(get_authenticated_user)):
2020-12-23 21:29:08 +00:00
host = request.headers.get("Host")
validate_login_request(data.response_data, data, user, "changePassword", host)
save_changed_password(data, user)
2020-12-25 10:36:06 +00:00
2020-12-28 08:00:35 +00:00
@authentication_router.post("/dashboard_url/", responses=permission_responses)
2020-12-29 11:22:36 +00:00
def dashboard_url(request: Request, user: UserType = Depends(get_authenticated_user)):
get_dashboard_url = app_settings.DASHBOARD_URL_FUNC
if get_dashboard_url is None:
2020-12-28 07:51:27 +00:00
raise HttpError("not_supported", "This server doesn't have a user dashboard.")
ret = {
2020-12-28 12:28:42 +00:00
"url": get_dashboard_url(CallbackContext(request.path_params, user=user)),
}
return ret
2020-12-29 11:22:36 +00:00
def signup_save(data: SignupIn, request: Request) -> UserType:
2020-12-25 10:36:06 +00:00
user_data = data.user
with transaction.atomic():
try:
2020-12-27 18:36:11 +00:00
user_queryset = get_user_queryset(User.objects.all(), CallbackContext(request.path_params))
2020-12-25 10:36:06 +00:00
instance = user_queryset.get(**{User.USERNAME_FIELD: user_data.username.lower()})
except User.DoesNotExist:
# Create the user and save the casing the user chose as the first name
try:
2020-12-27 18:36:11 +00:00
instance = create_user(
2020-12-28 15:46:20 +00:00
CallbackContext(request.path_params),
2020-12-27 18:36:11 +00:00
**user_data.dict(),
password=None,
first_name=user_data.username,
)
2020-12-25 10:36:06 +00:00
instance.full_clean()
2020-12-28 11:56:53 +00:00
except HttpError as e:
2020-12-25 10:36:06 +00:00
raise e
except django_exceptions.ValidationError as e:
2020-12-25 11:21:20 +00:00
transform_validation_error("user", e)
2020-12-25 10:36:06 +00:00
except Exception as e:
raise HttpError("generic", str(e))
2020-12-25 10:36:06 +00:00
if hasattr(instance, "userinfo"):
2020-12-28 07:51:27 +00:00
raise HttpError("user_exists", "User already exists", status_code=status.HTTP_409_CONFLICT)
2020-12-25 10:36:06 +00:00
2020-12-25 11:21:20 +00:00
models.UserInfo.objects.create(**data.dict(exclude={"user"}), owner=instance)
2020-12-25 10:36:06 +00:00
return instance
@authentication_router.post("/signup/", response_model=LoginOut, status_code=status.HTTP_201_CREATED)
2020-12-28 13:17:13 +00:00
def signup(data: SignupIn, request: Request):
user = signup_save(data, request)
2020-12-28 14:44:13 +00:00
ret = LoginOut.from_orm(user)
2020-12-28 13:17:13 +00:00
user_signed_up.send(sender=user.__class__, request=None, user=user)
2020-12-28 14:44:13 +00:00
return ret