Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
**/__pycache__
.github
.pytest_cache
.venv
credentials
db
Expand Down
87 changes: 44 additions & 43 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,59 +1,62 @@
FROM python:3.12
# FROM python:3.12-alpine
# FROM python:3.13-slim
FROM python:3.13-alpine

# can't install aerospike==8.0.0 on alpine
# RUN apk update && \
# apk upgrade && \
# apk add --no-cache git && \
# apk add --no-cache --virtual \
# .build-deps \
# gcc \
# g++ \
# musl-dev \
# libffi-dev \
# postgresql-dev \
# build-base \
# bash linux-headers \
# libuv libuv-dev \
# openssl openssl-dev \
# lua5.1 lua5.1-dev \
# zlib zlib-dev \
# python3-dev \
# exiftool

ENV DEBIAN_FRONTEND=noninteractive

RUN apt update && \
apt upgrade -y && \
apt install -y \
build-essential \
libssl-dev \
RUN apk update && \
apk upgrade && \
apk add --no-cache git && \
apk add --no-cache --virtual \
.build-deps \
gcc \
g++ \
musl-dev \
libffi-dev \
git \
jq \
libpq-dev \
postgresql-dev \
build-base \
bash linux-headers \
libuv libuv-dev \
openssl openssl-dev \
lua5.1 lua5.1-dev \
zlib zlib-dev \
python3-dev \
libpng-dev \
libjpeg-dev \
libtiff-dev \
libwebp-dev \
imagemagick \
libimage-exiftool-perl \
ffmpeg
exiftool \
jq

# ENV DEBIAN_FRONTEND=noninteractive

# RUN apt update && \
# apt upgrade -y && \
# apt install -y \
# build-essential \
# libssl-dev \
# libffi-dev \
# git \
# jq \
# libpq-dev \
# python3-dev \
# libpng-dev \
# libjpeg-dev \
# libtiff-dev \
# libwebp-dev \
# imagemagick \
# libimage-exiftool-perl \
# ffmpeg

RUN rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY . /app
RUN chmod +x docker-exec.sh

RUN mkdir "images"
RUN rm -rf .venv && \
rm -rf credentials

RUN wget https://go.dev/dl/go1.22.5.linux-amd64.tar.gz && \
tar -xvf go1.22.5.linux-amd64.tar.gz -C /usr/local && \
rm go1.22.5.linux-amd64.tar.gz

ENV GOROOT=/usr/local/go
# redefine $HOME to the default so that it stops complaining
ENV HOME="/root"
ENV GOPATH=$HOME/go
ENV PATH=$GOPATH/bin:$GOROOT/bin:$PATH

Expand All @@ -67,6 +70,4 @@ ENV PATH="/opt/.venv/bin:$PATH"

ENV PORT=80
ENV ENVIRONMENT=DEV
CMD jq '.fullchain' -r /etc/certs/cert.json > fullchain.pem && \
jq '.privkey' -r /etc/certs/cert.json > privkey.pem && \
gunicorn -w 2 -k uvicorn.workers.UvicornWorker --certfile fullchain.pem --keyfile privkey.pem -b 0.0.0.0:443 --timeout 1200 server:app
CMD ["./docker-exec.sh"]
2 changes: 1 addition & 1 deletion account/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from shared.exceptions.http_error import BadRequest, Conflict, HttpError, HttpErrorHandler, Unauthorized
from shared.hashing import Hashable
from shared.models.auth import AuthToken, Scope
from shared.server import Request
from shared.models.server import Request
from shared.sql import SqlInterface


Expand Down
18 changes: 3 additions & 15 deletions account/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@
from shared.config.constants import environment
from shared.datetime import datetime
from shared.exceptions.http_error import BadRequest
from shared.server import Request
from shared.models.server import Request

from .account import Account, auth
from .models import ChangeHandle, CreateAccountRequest, FinalizeAccountRequest, OtpFinalizeRequest, OtpRemoveEmailRequest, OtpRemoveRequest, OtpRequest


app = APIRouter(
prefix='/v1/account',
tags=['account'],
prefix = '/v1/account',
tags = ['account'],
)
account = Account()

Expand Down Expand Up @@ -108,19 +108,7 @@ async def v1BotLogin(body: BotLoginRequest) -> LoginResponse :
return await auth.botLogin(body.token)


@app.post('/bot/renew', response_model=BotCreateResponse)
async def v1BotRenew(req: Request) -> BotCreateResponse :
await req.user.verify_scope(Scope.internal)
return await auth.createBot(req.user, BotType.internal)


@app.get('/bot/create', response_model=BotCreateResponse)
async def v1BotCreate(req: Request) -> BotCreateResponse :
await req.user.verify_scope(Scope.user)
return await auth.createBot(req.user, BotType.bot)


@app.get('/bot/internal', response_model=BotCreateResponse)
async def v1BotCreateInternal(req: Request) -> BotCreateResponse :
await req.user.verify_scope(Scope.admin)
return await auth.createBot(req.user, BotType.internal)
80 changes: 47 additions & 33 deletions authenticator/authenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import Any, Awaitable, Callable, Dict, List, Optional, Self, Tuple
from uuid import UUID, uuid4

import aerospike
import pyotp
import ujson as json
from argon2 import PasswordHasher as Argon2
Expand All @@ -26,7 +27,7 @@
from shared.exceptions.http_error import BadRequest, Conflict, FailedLogin, HttpError, InternalServerError, NotFound, UnprocessableEntity
from shared.hashing import Hashable
from shared.models import InternalUser
from shared.models.auth import AuthState, AuthToken, KhUser, Scope, TokenMetadata
from shared.models.auth import AuthState, AuthToken, Scope, TokenMetadata, _KhUser
from shared.sql import SqlInterface
from shared.timing import timed
from shared.utilities.json import json_stream
Expand Down Expand Up @@ -89,7 +90,18 @@

BotLoginSerializer: AvroSerializer = AvroSerializer(BotLogin)
BotLoginDeserializer: AvroDeserializer = AvroDeserializer(BotLogin)
token_kvs: KeyValueStore = KeyValueStore('kheina', 'token')

try :
KeyValueStore._client.index_integer_create( # type: ignore
'kheina',
'token',
'user_id',
'kheina_token_user_id_idx',
)

except aerospike.exception.IndexFoundError :
pass

class BotTypeMap(SqlInterface):
@AsyncLRU(maxsize=0)
Expand Down Expand Up @@ -130,7 +142,6 @@ async def get_id(self: Self, key: BotType) -> int :
class Authenticator(SqlInterface, Hashable) :

EmailRegex = re_compile(r'^(?P<user>[A-Z0-9._%+-]+)@(?P<domain>[A-Z0-9.-]+\.[A-Z]{2,})$', flags=IGNORECASE)
KVS: KeyValueStore

def __init__(self) :
Hashable.__init__(self)
Expand All @@ -151,9 +162,6 @@ def __init__(self) :
'id': 0,
}

if not getattr(Authenticator, 'KVS', None) :
Authenticator.KVS = KeyValueStore('kheina', 'token')


def _validateEmail(self, email: str) -> Dict[str, str] :
e = Authenticator.EmailRegex.search(email)
Expand Down Expand Up @@ -201,18 +209,18 @@ async def generate_token(self, user_id: int, token_data: dict, ttl: Optional[int
start = self._calc_timestamp(issued)
end = start + self._key_refresh_interval
self._active_private_key = {
'key': None,
'key': None,
'algorithm': self._token_algorithm,
'issued': 0,
'start': start,
'end': end,
'id': 0,
'issued': 0,
'start': start,
'end': end,
'id': 0,
}

private_key = self._active_private_key['key'] = Ed25519PrivateKey.generate()
public_key = private_key.public_key().public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
encoding = serialization.Encoding.DER,
format = serialization.PublicFormat.SubjectPublicKeyInfo,
)
signature = private_key.sign(public_key)

Expand All @@ -237,10 +245,10 @@ async def generate_token(self, user_id: int, token_data: dict, ttl: Optional[int

# put the new key into the public keyring
self._public_keyring[(self._token_algorithm, key_id)] = {
'key': b64encode(public_key).decode(),
'key': b64encode(public_key).decode(),
'signature': b64encode(signature).decode(),
'issued': pk_issued,
'expires': pk_expires,
'issued': pk_issued,
'expires': pk_expires,
}

guid: UUID = uuid4()
Expand All @@ -255,29 +263,35 @@ async def generate_token(self, user_id: int, token_data: dict, ttl: Optional[int
])

token_info: TokenMetadata = TokenMetadata(
version=self._token_version.encode(),
state=AuthState.active,
issued=datetime.fromtimestamp(issued),
expires=datetime.fromtimestamp(expires),
key_id=key_id,
user_id=user_id,
algorithm=self._token_algorithm,
fingerprint=token_data.get('fp', '').encode(),
version = self._token_version.encode(),
state = AuthState.active,
issued = datetime.fromtimestamp(issued),
expires = datetime.fromtimestamp(expires),
key_id = key_id,
user_id = user_id,
algorithm = self._token_algorithm,
fingerprint = token_data.get('fp', '').encode(),
)
await token_kvs.put_async(
guid.bytes,
token_info,
ttl or self._token_expires_interval,
# additional bins for querying active logins
{ 'user_id': user_id },
)
await Authenticator.KVS.put_async(guid.bytes, token_info, ttl or self._token_expires_interval)

version = self._token_version.encode()
content = b64encode(version) + b'.' + b64encode(load)
signature = private_key.sign(content)
token = content + b'.' + b64encode(signature)

return TokenResponse(
version=self._token_version,
algorithm=self._token_algorithm, # type: ignore
key_id=key_id,
issued=issued, # type: ignore
expires=expires, # type: ignore
token=token.decode(),
version = self._token_version,
algorithm = self._token_algorithm, # type: ignore
key_id = key_id,
issued = issued, # type: ignore
expires = expires, # type: ignore
token = token.decode(),
)


Expand Down Expand Up @@ -440,7 +454,7 @@ async def login(self, email: str, password: str, otp: Optional[str], token_data:
)


async def createBot(self, user: KhUser, bot_type: BotType) -> BotCreateResponse :
async def createBot(self, user: _KhUser, bot_type: BotType) -> BotCreateResponse :
if type(bot_type) is not BotType :
# this should never run, thanks to pydantic/fastapi. just being extra careful.
raise BadRequest('bot_type must be a BotType value.')
Expand Down Expand Up @@ -709,11 +723,11 @@ async def create(self, handle: str, name: str, email: str, password: str, token_
raise InternalServerError('an error occurred during user creation.', logdata={ 'refid': refid })


async def create_otp(self: Self, user: KhUser) -> str :
async def create_otp(self: Self, user: _KhUser) -> str :
return pyotp.random_base32()


async def add_otp(self: Self, user: KhUser, email: str, otp_secret: str, otp: str) -> OtpAddedResponse :
async def add_otp(self: Self, user: _KhUser, email: str, otp_secret: str, otp: str) -> OtpAddedResponse :
if not pyotp.TOTP(otp_secret).verify(otp) :
raise BadRequest('failed to add OTP', email=email, user=user)

Expand Down
Loading