Skip to content

Commit

Permalink
add otp authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
kheina committed Nov 21, 2024
1 parent 09c3d9d commit 699499e
Show file tree
Hide file tree
Showing 32 changed files with 1,412 additions and 645 deletions.
150 changes: 128 additions & 22 deletions account/account.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,36 @@
from re import IGNORECASE
from re import compile as re_compile
from typing import Literal, Optional, Self

import pyotp
from psycopg.errors import UniqueViolation

from authenticator.authenticator import Authenticator
from authenticator.models import LoginResponse, TokenResponse
from authenticator.models import LoginResponse, OtpAddedResponse, OtpResponse, TokenResponse
from shared.auth import KhUser, browserFingerprint, verifyToken
from shared.config.constants import environment
from shared.config.constants import Environment, environment
from shared.email import Button, sendEmail
from shared.exceptions.http_error import BadRequest, Conflict, HttpError, HttpErrorHandler
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.sql import SqlInterface


auth = Authenticator()
OtpCreateKey: Literal['otp'] = 'otp'
OtpRemoveKey: Literal['remove-otp'] = 'remove-otp'
OtpIssuerName: Literal['fuzz.ly'] = 'fuzz.ly'


class Account(SqlInterface, Hashable) :

HandleRegex = re_compile(r'^[a-zA-Z0-9_]{5,}$')
EmailRegex = re_compile(r'^(?P<user>[A-Z0-9._%+-]+)@(?P<domain>[A-Z0-9.-]+\.[A-Z]{2,})$', flags=IGNORECASE)
VerifyEmailText = "Finish creating your new account at fuzz.ly by clicking the button below. If you didn't make this request, you can safely ignore this email."
HandleRegex = re_compile(r'^[a-zA-Z0-9_]{5,}$')
EmailRegex = re_compile(r'^(?P<user>[A-Z0-9._%+-]+)@(?P<domain>[A-Z0-9.-]+\.[A-Z]{2,})$', flags=IGNORECASE)
VerifyEmailText = "Finish creating your new account at fuzz.ly by clicking the button below. If you didn't make this request, you can safely ignore this email."
RemoveOtpText = "Remove the authenticator on your account by clicking the button below. If you didn't make this request, you can safely ignore this email."
VerifyEmailSubtext = 'fuzz.ly does not store your private information, including your email. You will not receive another email without directly requesting it.'
AccountCreateKey = 'create-account'
AccountCreateKey = 'create-account'
AccountRecoveryKey = 'recover-account'


Expand All @@ -32,13 +39,21 @@ def __init__(self: 'Account') :
SqlInterface.__init__(self)
self._auth_timeout = 30

if environment.is_prod() :
self._finalize_link = 'https://fuzz.ly/a/finalize?token={token}'
self._recovery_link = 'https://fuzz.ly/a/recovery?token={token}'
match environment :
case Environment.local :
self._finalize_link = 'http://localhost:3000/a/finalize?token={token}'
self._recovery_link = 'http://localhost:3000/a/recovery?token={token}'
self._remove_otp_link = 'http://localhost:3000/a/remove_otp?token={token}'

else :
self._finalize_link = 'https://dev.fuzz.ly/a/finalize?token={token}'
self._recovery_link = 'https://dev.fuzz.ly/a/recovery?token={token}'
case Environment.dev :
self._finalize_link = 'https://dev.fuzz.ly/a/finalize?token={token}'
self._recovery_link = 'https://dev.fuzz.ly/a/recovery?token={token}'
self._remove_otp_link = 'https://dev.fuzz.ly/a/remove_otp?token={token}'

case _ :
self._finalize_link = 'https://fuzz.ly/a/finalize?token={token}'
self._recovery_link = 'https://fuzz.ly/a/recovery?token={token}'
self._remove_otp_link = 'https://fuzz.ly/a/remove_otp?token={token}'


def _validateEmail(self: 'Account', email: str) :
Expand All @@ -63,7 +78,7 @@ def _validateHandle(self: 'Account', handle: str) :


@HttpErrorHandler('logging in user', exclusions=['self', 'password', 'request'])
async def login(self: 'Account', email: str, password: str, request: Request) -> LoginResponse :
async def login(self: 'Account', email: str, password: str, otp: Optional[str], request: Request) -> LoginResponse :
self._validateEmail(email)
self._validatePassword(password)

Expand All @@ -76,7 +91,7 @@ async def login(self: 'Account', email: str, password: str, request: Request) ->
'fp': browserFingerprint(request),
}

return await auth.login(email, password, token_data)
return await auth.login(email, password, otp, token_data)


@HttpErrorHandler('creating user account')
Expand All @@ -89,7 +104,16 @@ async def createAccount(self: 'Account', email: str, name: str) :
})

if environment.is_local() :
self.logger.info(f'server running in local environment. token data: {data}')
self.logger.info({
'message': f'server running in local environment, cannot send email',
'to': f'{name} <{email}>',
'subject': 'Finish your fuzz.ly account',
'title': f'Hey, {name}',
'text': Account.VerifyEmailText,
'button': Button(text='Finalize Account', link=self._finalize_link.format(token=data.token)),
'subtext': Account.VerifyEmailSubtext,
'token': data,
})

else :
await sendEmail(
Expand Down Expand Up @@ -133,12 +157,9 @@ async def finalizeAccount(self: 'Account', name: str, handle: str, password: str
(class_id, tag, owner)
VALUES
(tag_class_to_id(%s), %s, %s),
(tag_class_to_id(%s), %s, %s),
(tag_class_to_id(%s), %s, %s)
""",
(
""", (
'artist', f'{handle.lower()}_(artist)', data.user_id,
'sponsor', f'{handle.lower()}_(sponsor)', data.user_id,
'subject', f'{handle.lower()}_(subject)', data.user_id,
),
commit=True,
Expand All @@ -163,7 +184,7 @@ async def changePassword(self: 'Account', email: str, old_password: str, new_pas
@HttpErrorHandler('changing user handle', handlers = {
UniqueViolation: (Conflict, 'A user already exists with the provided handle.'),
})
async def changeHandle(self: 'Account', user: KhUser, handle: str) :
async def changeHandle(self: 'Account', user: KhUser, handle: str) -> None :
self._validateHandle(handle)
await self.query_async("""
UPDATE kheina.public.users
Expand All @@ -176,7 +197,7 @@ async def changeHandle(self: 'Account', user: KhUser, handle: str) :


@HttpErrorHandler('performing password recovery')
async def recoverPassword(self: 'Account', email: str) :
async def recoverPassword(self: 'Account', email: str) -> None :
self._validateEmail(email)

data: TokenResponse = await auth.generate_token(0, {
Expand All @@ -192,3 +213,88 @@ async def recoverPassword(self: 'Account', email: str) :
button=Button(text='Set New Password', link=self._recovery_link.format(token=data.token)),
subtext='If you did not initiate this account recovery, you do not need to do anything. However, someone may be trying to gain access to your account. Changing your passwords may be a good idea.',
)


async def create_otp(self: Self, user: KhUser, email: str, password: str) -> OtpResponse :
try :
await auth.login(email, password, None)

except Unauthorized :
raise Unauthorized('unable to add otp')

key: str = await auth.create_otp(user)
uri: str = pyotp.totp.TOTP(key).provisioning_uri(name=email, issuer_name=OtpIssuerName)
token = await auth.generate_token(
user.user_id,
{
'key': OtpCreateKey,
'otp_secret': key,
'email': email,
},
900,
)

return OtpResponse(
user_id = user.user_id,
uri = uri,
token = token,
)


async def finalize_otp(self: Self, user: KhUser, token: str, otp: str) -> OtpAddedResponse :
try :
token_data = await verifyToken(token)

except HttpError as e :
raise BadRequest('the otp confirmation key provided was invalid or could not be authenticated.', err=e)

if token_data.data.get('key') != OtpCreateKey :
raise BadRequest('the token provided does not match the purpose required.')

if token_data.user_id != user.user_id :
raise BadRequest('the token provided does not match the provided user.')

return await auth.add_otp(user, token_data.data['email'], token_data.data['otp_secret'], otp.strip())


async def request_remove_otp(self: Self, email: str) -> None :
self._validateEmail(email)

data: TokenResponse = await auth.generate_token(0, {
'email': email,
'key': OtpRemoveKey,
})

await sendEmail(
f'User <{email}>',
'Remove authenticator from your fuzz.ly account',
Account.RemoveOtpText,
title='Hey, fuzz.ly User',
button=Button(text='Remove Authenticator', link=self._remove_otp_link.format(token=data.token)),
subtext='If you did not initiate this action, you do not need to do anything. However, someone may be trying to gain access to your account. Changing your passwords may be a good idea.',
)


async def remove_otp(self: Self, user: Optional[KhUser], token: Optional[str], otp: Optional[str]) -> None :
if otp :
if not user or not user.token :
raise BadRequest('requires user to be logged in to remove via otp')

await user.verify_scope(Scope.user)
return await auth.remove_otp(user.token.data['email'], otp, None)

if not token :
raise BadRequest('requires valid otp or email token to remove otp auth.')

token_data: AuthToken

try :
token_data = await verifyToken(token)

except HttpError as e :
raise BadRequest('the token provided was invalid or could not be authenticated.', err=e)

if token_data.data.get('key') != OtpRemoveKey :
raise BadRequest('the token provided does not match the purpose required.')

return await auth.remove_otp(token_data.data['email'], None, token_data)
20 changes: 20 additions & 0 deletions account/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from typing import Optional
from pydantic import BaseModel


Expand All @@ -15,3 +16,22 @@ class FinalizeAccountRequest(BaseModel) :

class ChangeHandle(BaseModel) :
handle: str


class OtpRequest(BaseModel) :
email: str
password: str


class OtpFinalizeRequest(BaseModel) :
token: str
otp: str


class OtpRemoveEmailRequest(BaseModel) :
email: str


class OtpRemoveRequest(BaseModel) :
token: Optional[str]
otp: Optional[str]
30 changes: 26 additions & 4 deletions account/router.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
from fastapi import APIRouter
from fastapi.responses import Response

from authenticator.models import BotCreateResponse, BotLoginRequest, BotType, ChangePasswordRequest, LoginRequest, LoginResponse
from authenticator.models import BotCreateResponse, BotLoginRequest, BotType, ChangePasswordRequest, LoginRequest, LoginResponse, OtpAddedResponse, OtpResponse
from shared.auth import Scope, deactivateAuthToken
from shared.config.constants import environment
from shared.datetime import datetime
from shared.exceptions.http_error import BadRequest
from shared.server import Request

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


app = APIRouter(
Expand All @@ -26,7 +26,7 @@ async def shutdown() :

@app.post('/login', response_model=LoginResponse)
async def v1Login(req: Request, body: LoginRequest) :
auth = await account.login(body.email, body.password, req)
auth = await account.login(body.email, body.password, body.otp, req)
response = Response(auth.json(), headers={ 'content-type': 'application/json' })

if auth.token.token :
Expand Down Expand Up @@ -54,7 +54,7 @@ async def v1CreateAccount(body: CreateAccountRequest) :


@app.post('/finalize', response_model=LoginResponse)
async def v1FinalizeAccount(req: Request, body: FinalizeAccountRequest) :
async def v1FinalizeAccount(req: Request, body: FinalizeAccountRequest):
if not req.client :
raise BadRequest('how')

Expand All @@ -80,6 +80,28 @@ async def v1ChangeHandle(req: Request, body: ChangeHandle) :
await account.changeHandle(req.user, body.handle)


@app.put('/otp')
async def v1AddOtp(req: Request, body: OtpRequest) -> OtpResponse :
await req.user.verify_scope(Scope.user)
return await account.create_otp(req.user, body.email, body.password)


@app.patch('/otp')
async def v1FinalizeOtp(req: Request, body: OtpFinalizeRequest) -> OtpAddedResponse :
await req.user.verify_scope(Scope.user)
return await account.finalize_otp(req.user, body.token, body.otp)


@app.post('/otp/email', status_code = 204)
async def v1RemoveOtpEmail(body: OtpRemoveEmailRequest) -> None :
return await account.request_remove_otp(body.email)


@app.delete('/otp', status_code = 204)
async def v1RemoveOtp(req: Request, body: OtpRemoveRequest) -> None :
return await account.remove_otp(req.user, body.token, body.otp)


@app.post('/bot/login', response_model=LoginResponse)
async def v1BotLogin(body: BotLoginRequest) -> LoginResponse :
# this endpoint does not require auth
Expand Down
Loading

0 comments on commit 699499e

Please sign in to comment.