├── app ├── __init__.py ├── api │ ├── __init__.py │ ├── dtos │ │ ├── __init__.py │ │ └── oauth.py │ ├── responses.py │ └── oauth.py ├── models │ ├── __init__.py │ ├── user.py │ ├── api_hint.py │ ├── api_session.py │ └── api_scope.py ├── adapters │ ├── __init__.py │ ├── cryptography.py │ ├── password.py │ └── database.py ├── repositories │ ├── __init__.py │ ├── users.py │ └── api_sessions.py ├── logging.py ├── clients.py └── exception_handling.py ├── .gitignore ├── CODEOWNERS ├── requirements-dev.txt ├── README.md ├── scripts ├── run-service.sh ├── bootstrap.sh └── await-service.sh ├── .dockerignore ├── requirements.txt ├── Makefile ├── Dockerfile ├── logging.yaml ├── .env.example ├── chart └── values.yaml ├── LICENSE ├── .pre-commit-config.yaml ├── settings.py └── main.py /app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/adapters/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/api/dtos/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/repositories/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | __pycache__/ 3 | .vscode/ 4 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @cmyui @tsunyoku @infernalfire72 2 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | black 2 | pre-commit 3 | reorder-python-imports 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # akatsuki-lazer 2 | 3 | An attempt:tm: at supporting the new lazer client. 4 | -------------------------------------------------------------------------------- /scripts/run-service.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eo pipefail 3 | 4 | exec python3 main.py 5 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .env* 2 | .pre-commit-config.yaml 3 | *.example 4 | venv/ 5 | .vscode/ 6 | LICENSE 7 | CODEOWNERS 8 | logs.log 9 | -------------------------------------------------------------------------------- /app/adapters/cryptography.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | 3 | 4 | def calculate_md5(input: str) -> str: 5 | return hashlib.md5(input.encode()).hexdigest() 6 | -------------------------------------------------------------------------------- /app/models/user.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class User(BaseModel): 5 | id: int 6 | username: str 7 | hashed_password: str 8 | -------------------------------------------------------------------------------- /app/models/api_hint.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class ApiHint(str, Enum): 5 | USERNAME_OR_PASSWORD_INCORRECT = "The username or password is incorrect." 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bcrypt 2 | databases[asyncmy] 3 | fastapi 4 | orjson 5 | pydantic 6 | python-dotenv 7 | python-json-logger 8 | pyyaml 9 | redis 10 | uvicorn 11 | -------------------------------------------------------------------------------- /app/logging.py: -------------------------------------------------------------------------------- 1 | import logging.config 2 | 3 | import yaml 4 | 5 | 6 | def configure_logging() -> None: 7 | with open("logging.yaml") as f: 8 | config = yaml.safe_load(f.read()) 9 | logging.config.dictConfig(config) 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env make 2 | 3 | build: 4 | docker build -t akatsuki-lazer:latest . 5 | 6 | run: 7 | docker run --network=host --env-file=.env -it akatsuki-lazer:latest 8 | 9 | run-bg: 10 | docker run --network=host --env-file=.env -d akatsuki-lazer:latest 11 | -------------------------------------------------------------------------------- /app/models/api_session.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class ApiSession(BaseModel): 7 | token_type: str 8 | access_token: str 9 | refresh_token: str 10 | expires_at: datetime 11 | user_id: int 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10 2 | 3 | ENV PYTHONUNBUFFERED=1 4 | 5 | COPY requirements.txt . 6 | RUN pip install -r requirements.txt 7 | RUN pip install git+https://github.com/osuAkatsuki/akatsuki-cli 8 | 9 | COPY scripts /scripts 10 | 11 | COPY . /srv/root 12 | WORKDIR /srv/root 13 | 14 | ENTRYPOINT ["/scripts/bootstrap.sh"] 15 | -------------------------------------------------------------------------------- /app/models/api_scope.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class ApiScope(str, Enum): 5 | PUBLIC = "public" 6 | IDENTIFY = "identify" 7 | DELEGATE = "delegate" 8 | FORUM_WRITE = "forum.write" 9 | FRIENDS_READ = "friends.read" 10 | CHAT_WRITE = "chat.write" 11 | 12 | # thanks lazer 13 | ALL = "*" 14 | -------------------------------------------------------------------------------- /scripts/bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eo pipefail 3 | 4 | if [ -z "$APP_ENV" ]; then 5 | echo "Please set APP_ENV" 6 | exit 1 7 | fi 8 | 9 | if [[ $PULL_SECRETS_FROM_VAULT -eq 1 ]]; then 10 | akatsuki vault get akatsuki-lazer $APP_ENV -o .env 11 | source .env 12 | fi 13 | 14 | cd /srv/root 15 | 16 | # run the service 17 | exec /scripts/run-service.sh 18 | -------------------------------------------------------------------------------- /app/adapters/password.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import bcrypt 4 | 5 | 6 | async def verify_password(password: str, bcrypt_hash: str) -> bool: 7 | loop = asyncio.get_running_loop() 8 | result = await loop.run_in_executor( 9 | None, # default executor, 10 | bcrypt.checkpw, 11 | password.encode(), 12 | bcrypt_hash.encode(), 13 | ) 14 | 15 | return result 16 | -------------------------------------------------------------------------------- /app/adapters/database.py: -------------------------------------------------------------------------------- 1 | def create_database_url( 2 | dialect: str, 3 | user: str, 4 | host: str, 5 | port: int, 6 | database: str, 7 | driver: str | None = None, 8 | password: str | None = None, 9 | ) -> str: 10 | scheme = dialect 11 | if driver: 12 | scheme += f"+{driver}" 13 | if password: 14 | password = f":{password}" 15 | else: 16 | password = "" # nosec: B105 17 | 18 | return f"{scheme}://{user}{password}@{host}:{port}/{database}" 19 | -------------------------------------------------------------------------------- /logging.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | disable_existing_loggers: true 3 | loggers: 4 | app_logger: 5 | level: INFO 6 | handlers: [console] 7 | propagate: no 8 | handlers: 9 | console: 10 | class: logging.StreamHandler 11 | level: INFO 12 | formatter: json 13 | stream: ext://sys.stdout 14 | formatters: 15 | json: 16 | class: pythonjsonlogger.jsonlogger.JsonFormatter 17 | format: '%(asctime)s %(name)s %(levelname)s %(message)s' 18 | root: 19 | level: INFO 20 | handlers: [console] 21 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_ENV=production 2 | APP_HOST=127.0.0.1 3 | APP_PORT=11000 4 | 5 | CODE_HOTRELOAD=false 6 | 7 | DB_DIALECT=mysql 8 | DB_USER=mysql 9 | DB_HOST=mysql 10 | DB_PORT=3306 11 | DB_NAME=akatsuki 12 | DB_DRIVER=asyncmy 13 | DB_PASS=lol123 14 | DB_USE_SSL=false 15 | INITIALLY_AVAILABLE_DB=mysql 16 | 17 | REDIS_USER= 18 | REDIS_PASS= 19 | REDIS_HOST=redis 20 | REDIS_PORT=6379 21 | REDIS_DB=0 22 | 23 | ALLOWED_LAZER_CLIENT_IDS= 24 | ALLOWED_LAZER_CLIENT_SECRETS= 25 | 26 | OAUTH_ACCESS_TOKEN_VALIDITY_SECONDS=3600 27 | -------------------------------------------------------------------------------- /app/api/dtos/oauth.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import BaseModel 4 | 5 | from app.models.api_hint import ApiHint 6 | 7 | 8 | class OAuthSuccessResponse(BaseModel): 9 | token_type: str 10 | expires_in: int 11 | access_token: str 12 | refresh_token: str | None 13 | 14 | 15 | class OAuthFailureResponse(BaseModel): 16 | authentication: str | None 17 | hint: ApiHint | None 18 | 19 | 20 | class OAuthUnauthorizedResponse(BaseModel): 21 | authentication: Literal["basic"] 22 | -------------------------------------------------------------------------------- /app/repositories/users.py: -------------------------------------------------------------------------------- 1 | import app.clients 2 | from app.models.user import User 3 | 4 | 5 | async def get_by_username(username: str) -> User | None: 6 | safe_username = username.lower().replace(" ", "_") 7 | 8 | rec = await app.clients.database.fetch_one( 9 | "SELECT id, username, password_md5 AS hashed_password FROM users WHERE username_safe = :safe_username", 10 | {"safe_username": safe_username}, 11 | ) 12 | if rec is None: 13 | return None 14 | 15 | user = User.model_validate(dict(rec._mapping)) 16 | return user 17 | -------------------------------------------------------------------------------- /scripts/await-service.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -uo pipefail 3 | 4 | await_service() 5 | { 6 | local start_ts=$(date +%s) 7 | while [ $(date +%s) -lt $((start_ts + $3)) ]; 8 | do 9 | (echo -n > /dev/tcp/$1/$2) > /dev/null 10 | if [[ $? -eq 0 ]]; then 11 | break 12 | fi 13 | sleep 1 14 | done 15 | local end_ts=$(date +%s) 16 | 17 | if [ $(date +%s) -ge $((start_ts + $3)) ]; then 18 | echo "Timeout occurred while waiting for $1:$2 to become available" 19 | exit 1 20 | fi 21 | 22 | echo "$1:$2 is available after $((end_ts - start_ts)) seconds" 23 | } 24 | 25 | if [[ $# -ne 3 ]]; then 26 | echo "Usage: $0 " 27 | exit 1 28 | fi 29 | 30 | await_service $1 $2 $3 31 | -------------------------------------------------------------------------------- /app/api/responses.py: -------------------------------------------------------------------------------- 1 | from fastapi import status 2 | from fastapi.responses import ORJSONResponse 3 | 4 | from app.api.dtos.oauth import OAuthFailureResponse 5 | from app.api.dtos.oauth import OAuthUnauthorizedResponse 6 | from app.models.api_hint import ApiHint 7 | 8 | 9 | def create_unauthorized_response() -> ORJSONResponse: 10 | return ORJSONResponse( 11 | content=OAuthUnauthorizedResponse(authentication="basic"), 12 | status_code=status.HTTP_401_UNAUTHORIZED, 13 | ) 14 | 15 | 16 | def create_oauth_failure_response( 17 | authentication: str | None = None, 18 | hint: ApiHint | None = None, 19 | ) -> ORJSONResponse: 20 | return ORJSONResponse( 21 | content=OAuthFailureResponse(authentication=authentication, hint=hint), 22 | status_code=status.HTTP_400_BAD_REQUEST, 23 | ) 24 | -------------------------------------------------------------------------------- /app/clients.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from databases import Database 4 | from redis.asyncio import Redis 5 | 6 | import settings 7 | from app.adapters import database as database_adapter 8 | 9 | if TYPE_CHECKING: 10 | ... 11 | 12 | database = Database( 13 | url=database_adapter.create_database_url( 14 | dialect=settings.DB_DIALECT, 15 | driver=settings.DB_DRIVER, 16 | host=settings.DB_HOST, 17 | port=settings.DB_PORT, 18 | database=settings.DB_NAME, 19 | user=settings.DB_USER, 20 | password=settings.DB_PASS, 21 | ), 22 | ) 23 | redis = Redis( 24 | host=settings.REDIS_HOST, 25 | port=settings.REDIS_PORT, 26 | db=settings.REDIS_DB, 27 | username=settings.REDIS_USER, 28 | password=settings.REDIS_PASS, 29 | ssl=settings.REDIS_USE_SSL, 30 | ) 31 | -------------------------------------------------------------------------------- /chart/values.yaml: -------------------------------------------------------------------------------- 1 | apps: 2 | - name: akatsuki-lazer-api 3 | environment: production 4 | codebase: akatsuki-lazer 5 | replicaCount: 1 6 | container: 7 | image: 8 | repository: osuakatsuki/akatsuki-lazer 9 | tag: latest 10 | port: 80 11 | readinessProbe: 12 | httpGet: 13 | path: /_health 14 | port: 80 15 | initialDelaySeconds: 10 16 | periodSeconds: 10 17 | timeoutSeconds: 1 18 | successThreshold: 1 19 | failureThreshold: 3 20 | resources: 21 | limits: 22 | cpu: 300m 23 | memory: 250Mi 24 | requests: 25 | cpu: 150m 26 | memory: 150Mi 27 | env: 28 | - name: APP_COMPONENT 29 | value: api 30 | imagePullSecrets: 31 | - name: osuakatsuki-registry-secret 32 | service: 33 | type: ClusterIP 34 | port: 80 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 James Wilson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/repositories/api_sessions.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import app.clients 4 | from app.models.api_session import ApiSession 5 | 6 | 7 | def format_redis_key(access_token: str) -> str: 8 | return f"akatsuki:lazer:sessions:{access_token}" 9 | 10 | 11 | async def add( 12 | token_type: str, 13 | access_token: str, 14 | refresh_token: str, 15 | expires_at: datetime, 16 | user_id: int, 17 | ) -> ApiSession: 18 | api_session = ApiSession( 19 | token_type=token_type, 20 | access_token=access_token, 21 | refresh_token=refresh_token, 22 | expires_at=expires_at, 23 | user_id=user_id, 24 | ) 25 | await app.clients.redis.set( 26 | name=format_redis_key(access_token), 27 | value=api_session.model_dump_json(), 28 | ex=expires_at - datetime.utcnow(), 29 | ) 30 | 31 | return api_session 32 | 33 | 34 | async def get_by_access_token(access_token: str) -> ApiSession | None: 35 | redis_session = await app.clients.redis.get(name=format_redis_key(access_token)) 36 | if redis_session is None: 37 | return None 38 | 39 | api_session = ApiSession.model_validate_json(redis_session) 40 | return api_session 41 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.3.0 6 | hooks: 7 | - id: check-ast 8 | - id: check-builtin-literals 9 | - id: check-yaml 10 | - id: debug-statements 11 | - id: end-of-file-fixer 12 | - id: requirements-txt-fixer 13 | - id: trailing-whitespace 14 | - repo: https://github.com/psf/black 15 | rev: 22.3.0 16 | hooks: 17 | - id: black 18 | - repo: https://github.com/asottile/pyupgrade 19 | rev: v2.34.0 20 | hooks: 21 | - id: pyupgrade 22 | args: [--py310-plus, --keep-runtime-typing] 23 | - repo: https://github.com/asottile/reorder_python_imports 24 | rev: v3.1.0 25 | hooks: 26 | - id: reorder-python-imports 27 | args: [--py310-plus] 28 | - repo: https://github.com/asottile/add-trailing-comma 29 | rev: v2.2.3 30 | hooks: 31 | - id: add-trailing-comma 32 | - repo: https://github.com/asottile/blacken-docs 33 | rev: v1.12.1 34 | hooks: 35 | - id: blacken-docs 36 | additional_dependencies: [black==21.11b1] 37 | 38 | default_language_version: 39 | python: python3.10 40 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from dotenv import load_dotenv 4 | 5 | load_dotenv() 6 | 7 | 8 | def read_bool(value: str) -> bool: 9 | return value.lower() in ("1", "true") 10 | 11 | 12 | def read_list(value: str) -> list[str]: 13 | return value.split(",") 14 | 15 | 16 | APP_ENV = os.environ["APP_ENV"] 17 | APP_HOST = os.environ["APP_HOST"] 18 | APP_PORT = int(os.environ["APP_PORT"]) 19 | 20 | CODE_HOTRELOAD = read_bool(os.environ["CODE_HOTRELOAD"]) 21 | 22 | DB_DIALECT = os.environ["DB_DIALECT"] 23 | DB_USER = os.environ["DB_USER"] 24 | DB_HOST = os.environ["DB_HOST"] 25 | DB_PORT = int(os.environ["DB_PORT"]) 26 | DB_NAME = os.environ["DB_NAME"] 27 | DB_DRIVER = os.environ["DB_DRIVER"] 28 | DB_PASS = os.environ["DB_PASS"] 29 | INITIALLY_AVAILABLE_DB = os.environ["INITIALLY_AVAILABLE_DB"] 30 | 31 | REDIS_USER = os.environ.get("REDIS_USER") 32 | REDIS_PASS = os.environ.get("REDIS_PASS") 33 | REDIS_HOST = os.environ["REDIS_HOST"] 34 | REDIS_PORT = int(os.environ["REDIS_PORT"]) 35 | REDIS_DB = int(os.environ["REDIS_DB"]) 36 | REDIS_USE_SSL = read_bool(os.environ["REDIS_USE_SSL"]) 37 | 38 | ALLOWED_LAZER_CLIENT_IDS = read_list(os.environ["ALLOWED_LAZER_CLIENT_IDS"]) 39 | ALLOWED_LAZER_CLIENT_SECRETS = read_list(os.environ["ALLOWED_LAZER_CLIENT_SECRETS"]) 40 | 41 | OAUTH_ACCESS_TOKEN_VALIDITY_SECONDS = int( 42 | os.environ["OAUTH_ACCESS_TOKEN_VALIDITY_SECONDS"], 43 | ) 44 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import atexit 3 | from collections.abc import AsyncIterator 4 | from contextlib import asynccontextmanager 5 | 6 | import uvicorn 7 | from fastapi import FastAPI 8 | from fastapi.responses import ORJSONResponse 9 | 10 | import app.clients 11 | import app.exception_handling 12 | import app.logging 13 | import settings 14 | from app.api import oauth 15 | 16 | 17 | @asynccontextmanager 18 | async def lifespan(asgi_app: FastAPI) -> AsyncIterator[None]: 19 | try: 20 | await app.clients.database.connect() 21 | await app.clients.redis.ping() 22 | yield 23 | finally: 24 | await app.clients.database.disconnect() 25 | await app.clients.redis.aclose() 26 | 27 | 28 | asgi_app = FastAPI(lifespan=lifespan, default_response_class=ORJSONResponse) 29 | 30 | 31 | @asgi_app.get("/_health") 32 | async def health(): 33 | return {"status": "ok"} 34 | 35 | 36 | asgi_app.include_router(oauth.router) 37 | 38 | 39 | def main() -> int: 40 | app.logging.configure_logging() 41 | 42 | app.exception_handling.hook_exception_handlers() 43 | atexit.register(app.exception_handling.unhook_exception_handlers) 44 | 45 | uvicorn.run( 46 | "main:asgi_app", 47 | reload=settings.CODE_HOTRELOAD, 48 | server_header=False, 49 | date_header=False, 50 | host=settings.APP_HOST, 51 | port=settings.APP_PORT, 52 | access_log=False, 53 | ) 54 | return 0 55 | 56 | 57 | if __name__ == "__main__": 58 | raise SystemExit(main()) 59 | -------------------------------------------------------------------------------- /app/exception_handling.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import sys 5 | import threading 6 | from collections.abc import Callable 7 | from types import TracebackType 8 | from typing import Any 9 | from typing import Optional 10 | 11 | ExceptionHook = Callable[ 12 | [type[BaseException], BaseException, Optional[TracebackType]], 13 | Any, 14 | ] 15 | ThreadingExceptionHook = Callable[[threading.ExceptHookArgs], Any] 16 | 17 | _default_excepthook: ExceptionHook | None = None 18 | _default_threading_excepthook: ThreadingExceptionHook | None = None 19 | 20 | 21 | def internal_exception_handler( 22 | exc_type: type[BaseException], 23 | exc_value: BaseException, 24 | exc_traceback: TracebackType | None, 25 | ) -> None: 26 | logging.exception( 27 | "An unhandled exception occurred", 28 | exc_info=(exc_type, exc_value, exc_traceback), 29 | ) 30 | 31 | 32 | def internal_thread_exception_handler( 33 | args: threading.ExceptHookArgs, 34 | ) -> None: 35 | if args.exc_value is None: # pragma: no cover 36 | logging.warning("Exception hook called without exception value.") 37 | return 38 | 39 | logging.exception( 40 | "An unhandled exception occurred", 41 | exc_info=(args.exc_type, args.exc_value, args.exc_traceback), 42 | extra={"thread_vars": vars(args.thread)}, 43 | ) 44 | 45 | 46 | def hook_exception_handlers() -> None: 47 | global _default_excepthook 48 | _default_excepthook = sys.excepthook 49 | sys.excepthook = internal_exception_handler 50 | 51 | global _default_threading_excepthook 52 | _default_threading_excepthook = threading.excepthook 53 | threading.excepthook = internal_thread_exception_handler 54 | 55 | 56 | def unhook_exception_handlers() -> None: 57 | global _default_excepthook 58 | if _default_excepthook is not None: 59 | sys.excepthook = _default_excepthook 60 | 61 | global _default_threading_excepthook 62 | if _default_threading_excepthook is not None: 63 | threading.excepthook = _default_threading_excepthook 64 | -------------------------------------------------------------------------------- /app/api/oauth.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | from datetime import datetime 3 | from datetime import timedelta 4 | 5 | from fastapi import APIRouter 6 | from fastapi import Form 7 | from fastapi.responses import ORJSONResponse 8 | 9 | import settings 10 | from app.adapters import cryptography as cryptography_adapter 11 | from app.adapters import password as password_adapter 12 | from app.api import responses 13 | from app.api.dtos.oauth import OAuthFailureResponse 14 | from app.api.dtos.oauth import OAuthSuccessResponse 15 | from app.api.dtos.oauth import OAuthUnauthorizedResponse 16 | from app.models.api_hint import ApiHint 17 | from app.models.api_scope import ApiScope 18 | from app.repositories import api_sessions as api_sessions_repository 19 | from app.repositories import users as users_repository 20 | 21 | router = APIRouter(default_response_class=ORJSONResponse) 22 | 23 | 24 | @router.post( 25 | "/oauth/token", 26 | response_model=OAuthSuccessResponse 27 | | OAuthFailureResponse 28 | | OAuthUnauthorizedResponse, 29 | ) 30 | async def oauth_token( 31 | username: str | None = Form(None), 32 | password: str | None = Form(None), 33 | grant_type: str = Form(...), 34 | client_id: int = Form(...), 35 | client_secret: str = Form(...), 36 | scope: str = Form(...), 37 | ): 38 | requested_scopes = [ 39 | ApiScope(requested_scope) for requested_scope in scope.split(",") 40 | ] 41 | 42 | # if it's a lazer client then it must have ApiScope.ALL 43 | # we will maybe support oauth for non lazer clients at some point 44 | if ApiScope.ALL not in requested_scopes: 45 | return responses.create_unauthorized_response() 46 | 47 | # lazer has static client ids & secrets per environment 48 | # ref: https://github.com/ppy/osu/blob/master/osu.Game/Online/DevelopmentEndpointConfiguration.cs 49 | # ref: https://github.com/ppy/osu/blob/master/osu.Game/Online/ExperimentalEndpointConfiguration.cs 50 | # ref: https://github.com/ppy/osu/blob/master/osu.Game/Online/ProductionEndpointConfiguration.cs 51 | if ( 52 | client_id not in settings.ALLOWED_LAZER_CLIENT_IDS 53 | or client_secret not in settings.ALLOWED_LAZER_CLIENT_SECRETS 54 | ): 55 | return responses.create_unauthorized_response() 56 | 57 | # lazer can only use the password grant type 58 | # TODO: grant type enum 59 | if grant_type != "password": 60 | return responses.create_unauthorized_response() 61 | 62 | if username is None or password is None: 63 | return responses.create_oauth_failure_response( 64 | hint=ApiHint.USERNAME_OR_PASSWORD_INCORRECT, 65 | ) 66 | 67 | password_md5 = cryptography_adapter.calculate_md5(password) 68 | 69 | user = await users_repository.get_by_username(username) 70 | if user is None: 71 | return responses.create_oauth_failure_response( 72 | hint=ApiHint.USERNAME_OR_PASSWORD_INCORRECT, 73 | ) 74 | 75 | correct_password = await password_adapter.verify_password( 76 | password_md5, 77 | user.hashed_password, 78 | ) 79 | if not correct_password: 80 | return responses.create_oauth_failure_response( 81 | hint=ApiHint.USERNAME_OR_PASSWORD_INCORRECT, 82 | ) 83 | 84 | access_token = secrets.token_urlsafe(16) 85 | refresh_token = secrets.token_urlsafe(16) 86 | expires_at = datetime.utcnow() + timedelta( 87 | seconds=settings.OAUTH_ACCESS_TOKEN_VALIDITY_SECONDS, 88 | ) 89 | 90 | api_session = await api_sessions_repository.add( 91 | token_type="Bearer", 92 | access_token=access_token, 93 | refresh_token=refresh_token, 94 | expires_at=expires_at, 95 | user_id=user.id, 96 | ) 97 | 98 | expires_in = api_session.expires_at - datetime.utcnow() 99 | expires_in_seconds = int(expires_in.total_seconds()) 100 | 101 | return OAuthSuccessResponse( 102 | token_type=api_session.token_type, 103 | expires_in=expires_in_seconds, 104 | access_token=api_session.access_token, 105 | refresh_token=api_session.refresh_token, 106 | ) 107 | --------------------------------------------------------------------------------