Skip to content

Commit

Permalink
Update username, password & email APIs; X-Ripple-Token authorization (#4
Browse files Browse the repository at this point in the history
)

* Authorized username update API

* deploy that shit out

* Bugfix

* Pull args from body

* Fix unmapped codes

* Lock name changes to donors

* Fix

* APIs to update user email & password

* Add APIs

* Fixes

* Edit API

* Log validation errors

* Fix authorization

* Fixes

* security irl

* fixes & make apis harder to fuck up

* Update .github/workflows/production-deploy.yml
  • Loading branch information
cmyui authored Aug 8, 2024
1 parent 6497d16 commit 884ded6
Show file tree
Hide file tree
Showing 10 changed files with 353 additions and 14 deletions.
8 changes: 4 additions & 4 deletions .github/workflows/production-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ jobs:

- name: Get kubeconfig from github secrets
run: |
mkdir -p $HOME/.kube
echo "${{ secrets.KUBECONFIG }}" > $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
chmod 600 $HOME/.kube/config
mkdir -p $HOME/.kube
echo "${{ secrets.KUBECONFIG }}" > $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
chmod 600 $HOME/.kube/config
- name: Install helm
uses: azure/setup-helm@v3
Expand Down
31 changes: 31 additions & 0 deletions app/api/authorization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import hashlib
from app.errors import Error, ErrorCode
from app.repositories import access_tokens


async def authorize_request(
*,
user_access_token: str,
expected_user_id: int | None = None,
) -> access_tokens.AccessToken | Error:
hashed_access_token = hashlib.md5(
user_access_token.encode(),
usedforsecurity=False,
).hexdigest()
trusted_access_token = await access_tokens.fetch_one(hashed_access_token)
if trusted_access_token is None:
return Error(
error_code=ErrorCode.INCORRECT_CREDENTIALS,
user_feedback="Unauthorized request",
)

if (
expected_user_id is not None
and trusted_access_token.user_id != expected_user_id
):
return Error(
error_code=ErrorCode.INCORRECT_CREDENTIALS,
user_feedback="Unauthorized request",
)

return trusted_access_token
122 changes: 120 additions & 2 deletions app/api/public/users.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from fastapi import APIRouter
import logging
from fastapi import APIRouter, Cookie
from fastapi import Response
from pydantic import BaseModel

from app.api import authorization
from app.api.responses import JSONResponse
from app.errors import Error
from app.errors import ErrorCode
Expand All @@ -10,14 +13,23 @@


def map_error_code_to_http_status_code(error_code: ErrorCode) -> int:
return _error_code_to_http_status_code_map[error_code]
status_code = _error_code_to_http_status_code_map.get(error_code)
if status_code is None:
logging.warning(
"No HTTP status code mapping found for error code: %s",
error_code,
extra={"error_code": error_code},
)
return 500
return status_code


_error_code_to_http_status_code_map: dict[ErrorCode, int] = {
ErrorCode.INCORRECT_CREDENTIALS: 401,
ErrorCode.INSUFFICIENT_PRIVILEGES: 401,
ErrorCode.PENDING_VERIFICATION: 401,
ErrorCode.NOT_FOUND: 404,
ErrorCode.CONFLICT: 409,
ErrorCode.INTERNAL_SERVER_ERROR: 500,
}

Expand All @@ -35,3 +47,109 @@ async def get_user(user_id: int) -> Response:
content=response.model_dump(),
status_code=200,
)


class UsernameUpdate(BaseModel):
new_username: str


@router.put("/public/api/v1/users/{user_id}/username")
async def update_username(
user_id: int,
args: UsernameUpdate,
user_access_token: str = Cookie(..., alias="X-Ripple-Token", strict=True),
) -> Response:
trusted_access_token = await authorization.authorize_request(
user_access_token=user_access_token,
expected_user_id=user_id,
)
if isinstance(trusted_access_token, Error):
return JSONResponse(
content=trusted_access_token.model_dump(),
status_code=map_error_code_to_http_status_code(
trusted_access_token.error_code
),
)

response = await users.update_username(user_id, new_username=args.new_username)
if isinstance(response, Error):
return JSONResponse(
content=response.model_dump(),
status_code=map_error_code_to_http_status_code(response.error_code),
)

return Response(status_code=204)


class PasswordUpdate(BaseModel):
current_password: str
new_password: str


@router.put("/public/api/v1/users/{user_id}/password")
async def update_password(
user_id: int,
args: PasswordUpdate,
user_access_token: str = Cookie(..., alias="X-Ripple-Token", strict=True),
) -> Response:
trusted_access_token = await authorization.authorize_request(
user_access_token=user_access_token,
expected_user_id=user_id,
)
if isinstance(trusted_access_token, Error):
return JSONResponse(
content=trusted_access_token.model_dump(),
status_code=map_error_code_to_http_status_code(
trusted_access_token.error_code
),
)

response = await users.update_password(
user_id,
current_password=args.current_password,
new_password=args.new_password,
)
if isinstance(response, Error):
return JSONResponse(
content=response.model_dump(),
status_code=map_error_code_to_http_status_code(response.error_code),
)

return Response(status_code=204)


class EmailAddressUpdate(BaseModel):
current_password: str
new_email_address: str


@router.put("/public/api/v1/users/{user_id}/email-address")
async def update_email_address(
user_id: int,
args: EmailAddressUpdate,
user_access_token: str = Cookie(..., alias="X-Ripple-Token", strict=True),
) -> Response:
trusted_access_token = await authorization.authorize_request(
user_access_token=user_access_token,
expected_user_id=user_id,
)
if isinstance(trusted_access_token, Error):
return JSONResponse(
content=trusted_access_token.model_dump(),
status_code=map_error_code_to_http_status_code(
trusted_access_token.error_code
),
)

response = await users.update_email_address(
user_id,
current_password=args.current_password,
new_email_address=args.new_email_address,
)
if isinstance(response, Error):
return JSONResponse(
content=response.model_dump(),
status_code=map_error_code_to_http_status_code(response.error_code),
)

return Response(status_code=204)
1 change: 1 addition & 0 deletions app/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class ErrorCode(StrEnum):
INSUFFICIENT_PRIVILEGES = "INSUFFICIENT_PRIVILEGES"
PENDING_VERIFICATION = "PENDING_VERIFICATION"
NOT_FOUND = "NOT_FOUND"
CONFLICT = "CONFLICT"

INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR"

Expand Down
15 changes: 14 additions & 1 deletion app/init_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@
from databases import Database
from fastapi import FastAPI
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi import Response
from starlette.middleware.base import RequestResponseEndpoint

from fastapi.exception_handlers import request_validation_exception_handler
from app import logger
from app import settings
from app import state
from app.adapters import mysql
from app.api import api_router
from fastapi.responses import JSONResponse


@asynccontextmanager
Expand Down Expand Up @@ -53,6 +55,17 @@ async def http_middleware(
logging.exception("Exception in ASGI application")
return Response(status_code=500)

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
logging.warning(
"Request validation failed",
extra={"body": exc.body, "path": request.url.path},
exc_info=exc,
)
return await request_validation_exception_handler(request, exc)

return app


Expand Down
20 changes: 20 additions & 0 deletions app/repositories/access_tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,23 @@ async def create(*, user_id: int, access_token: str) -> AccessToken:
description=rec["description"],
private=rec["private"],
)


async def fetch_one(access_token: str) -> AccessToken | None:
query = """\
SELECT user, privileges, description, private
FROM tokens
WHERE token = :access_token
"""
params = {"access_token": access_token}
rec = await app.state.database.fetch_one(query, params)
if rec is None:
return None

return AccessToken(
access_token=access_token,
user_id=rec["user"],
privileges=UserPrivileges(rec["privileges"]),
description=rec["description"],
private=rec["private"],
)
57 changes: 57 additions & 0 deletions app/repositories/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,60 @@ async def fetch_one_by_user_id(user_id: int) -> User | None:
silence_reason=user["silence_reason"],
silence_end=user["silence_end"],
)


async def username_is_taken(username: str) -> bool:
query = """\
SELECT 1
FROM users
WHERE username_safe = :username_safe
"""
username_safe = username.lower().replace(" ", "_")
params = {"username_safe": username_safe}

return await app.state.database.fetch_one(query, params) is not None


async def update_username(user_id: int, new_username: str) -> None:
query = """\
UPDATE users
SET username = :new_username,
username_safe = :new_username_safe
WHERE id = :user_id
"""
new_username_safe = new_username.lower().replace(" ", "_")
params = {
"new_username": new_username,
"new_username_safe": new_username_safe,
"user_id": user_id,
}

await app.state.database.execute(query, params)


async def update_password(user_id: int, *, new_hashed_password: str) -> None:
query = """\
UPDATE users
SET password_md5 = :new_hashed_password
WHERE id = :user_id
"""
params = {
"new_hashed_password": new_hashed_password,
"user_id": user_id,
}

await app.state.database.execute(query, params)


async def update_email_address(user_id: int, new_email_address: str) -> None:
query = """\
UPDATE users
SET email = :new_email_address
WHERE id = :user_id
"""
params = {
"new_email_address": new_email_address,
"user_id": user_id,
}

await app.state.database.execute(query, params)
26 changes: 20 additions & 6 deletions app/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,31 @@

def hash_osu_password(password: str) -> str:
return bcrypt.hashpw(
hashlib.md5(password.encode()).hexdigest().encode(),
bcrypt.gensalt(),
password=hashlib.md5(
password.encode(),
usedforsecurity=False,
)
.hexdigest()
.encode(),
salt=bcrypt.gensalt(),
).decode()


def check_osu_password(password: str, hashed_password: str) -> bool:
def check_osu_password(
*,
untrusted_password: str,
hashed_password: str,
) -> bool:
return bcrypt.checkpw(
hashlib.md5(password.encode()).hexdigest().encode(),
hashed_password.encode(),
password=hashlib.md5(
untrusted_password.encode(),
usedforsecurity=False,
)
.hexdigest()
.encode(),
hashed_password=hashed_password.encode(),
)


def generate_access_token() -> str:
return secrets.token_urlsafe(32)
return secrets.token_urlsafe(nbytes=32)
5 changes: 4 additions & 1 deletion app/usecases/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ async def authenticate(
user_feedback="Incorrect username or password.",
)

if not security.check_osu_password(password, user.hashed_password):
if not security.check_osu_password(
untrusted_password=password,
hashed_password=user.hashed_password,
):
return Error(
error_code=ErrorCode.INCORRECT_CREDENTIALS,
user_feedback="Incorrect username or password.",
Expand Down
Loading

0 comments on commit 884ded6

Please sign in to comment.