Skip to content

Commit 78e2b93

Browse files
committed
Web3 auth user creation
1 parent 7468b8f commit 78e2b93

5 files changed

Lines changed: 214 additions & 145 deletions

File tree

brood/actions.py

Lines changed: 74 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
11
"""
22
User-related Brood operations
33
"""
4+
import base64
5+
import json
46
import logging
57
import re
8+
import sys
69
import uuid
710
from datetime import datetime, timedelta
811
from random import randint
912
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, cast
1013

11-
import stripe # type: ignore
1214
from passlib.context import CryptContext
1315
from sendgrid import SendGridAPIClient
1416
from sendgrid.helpers.mail import Mail
1517
from sqlalchemy import and_, func, or_
18+
from sqlalchemy.exc import IntegrityError
1619
from sqlalchemy.orm import Query
17-
from sqlalchemy.orm.base import PASSIVE_OFF
1820
from sqlalchemy.orm.exc import MultipleResultsFound
1921
from sqlalchemy.orm.session import Session
22+
from web3login.auth import MoonstreamRegistration, to_checksum_address, verify
23+
from web3login.exceptions import MoonstreamVerificationError
2024

2125
from . import data, exceptions, subscriptions
2226
from .models import (
@@ -411,43 +415,80 @@ def password_confirm(
411415
def create_user(
412416
session: Session,
413417
username: str,
414-
email: str,
415-
password: str,
418+
email: Optional[str] = None,
419+
password: Optional[str] = None,
420+
signature: Optional[str] = None,
416421
autogenerated_user: bool = False,
417422
first_name: Optional[str] = None,
418423
last_name: Optional[str] = None,
419424
application_id: Optional[uuid.UUID] = None,
425+
is_verify: bool = False,
420426
) -> User:
421427
"""
422-
Creates a new user in the given database session and
423-
returns the created user object.
428+
Creates a new user in the given database session and returns
429+
the created user object.
424430
425-
According with autogenerated_user var it create bugout user for Slack/Github installation or
426-
normal user.
431+
According with autogenerated_user var it create bugout user for
432+
Slack/Github installation or normal user.
427433
428434
Sessions are expected to be sqlalchemy Session objects:
429435
https://docs.sqlalchemy.org/en/13/orm/session_api.html#sqlalchemy.orm.session.Session
430436
"""
437+
# Check username correctness
431438
assert username != ""
432-
assert email != ""
433-
assert password != ""
434-
435-
# Username and email should be stored as lowercase strings in the database.
436439
username = username.lower()
437-
normalized_email = normalize_email(email)
438-
439440
verify_username(username)
440-
verify_password_strength(password)
441441

442-
password_context = get_password_context()
443-
password_hash = password_context.hash(password)
442+
is_creation_allowed = False
443+
444+
# Regular user with password and email creation
445+
normalized_email = None
446+
password_hash = None
447+
if email is not None and password is not None:
448+
assert email != ""
449+
assert password != ""
450+
451+
# Email should be stored as lowercase strings in the database
452+
normalized_email = normalize_email(email)
453+
verify_password_strength(password)
454+
password_context = get_password_context()
455+
password_hash = password_context.hash(password)
456+
457+
is_creation_allowed = True
458+
459+
# Web3 user with blockchain address creation
460+
web3_address = None
461+
if signature is not None:
462+
payload_json = base64.decodebytes(signature.encode()).decode("utf-8")
463+
payload = json.loads(payload_json)
464+
if not isinstance(MoonstreamRegistration, MoonstreamRegistration):
465+
# Mypy hell
466+
raise Exception()
467+
verified = verify(authorization_payload=payload, schema=MoonstreamRegistration)
468+
if not verified:
469+
logger.info("Moonstream registration verification error")
470+
raise MoonstreamVerificationError()
471+
web3_address = payload.get("address")
472+
if web3_address is None:
473+
logger.error(
474+
f"Web3 address in payload could not be None for user with username: {username}"
475+
)
476+
raise Exception()
477+
web3_address = to_checksum_address(web3_address)
478+
479+
is_creation_allowed = True
480+
481+
if not is_creation_allowed:
482+
logger.info("Signature or email with password should be specified")
483+
raise UserInvalidParameters()
444484

445485
user_object = User(
446486
username=username,
447487
email=email,
448488
normalized_email=normalized_email,
449489
password_hash=password_hash,
450-
verified=True if autogenerated_user else False,
490+
web3_address=web3_address,
491+
verified=True if autogenerated_user else is_verify,
451492
autogenerated=True if autogenerated_user else False,
452493
first_name=first_name,
453494
last_name=last_name,
@@ -460,9 +501,16 @@ def create_user(
460501
session.add(user_object)
461502
session.add(user_group_limit)
462503
session.commit()
463-
except Exception as e:
464-
logger.error(e)
465-
raise UserAlreadyExists("This user already exists")
504+
except IntegrityError:
505+
logger.info(
506+
f"User already exists with username: {username}, email: {email}, address: {web3_address if signature is not None else None}"
507+
)
508+
raise UserAlreadyExists()
509+
except Exception:
510+
logger.error(
511+
f"Unable to add user with username: {username}, email: {email}, address: {web3_address if signature is not None else None}"
512+
)
513+
raise Exception()
466514

467515
return user_object
468516

@@ -511,7 +559,7 @@ def get_user(
511559
user_id: Optional[uuid.UUID] = None,
512560
application_id: Optional[uuid.UUID] = None,
513561
web3_address: Optional[uuid.UUID] = None,
514-
) -> data.UserResponse:
562+
) -> User:
515563
"""
516564
Get a user by username, email, user_id or web3_address.
517565
If more than one of those fields is provided, will look for a user having ALL the given parameters.
@@ -635,9 +683,7 @@ def get_user_with_groups(
635683
)
636684

637685

638-
def get_current_user_by_token(
639-
session: Session, token: uuid.UUID
640-
) -> Tuple[bool, data.UserResponse]:
686+
def get_current_user_by_token(session: Session, token: uuid.UUID) -> Tuple[bool, User]:
641687
"""
642688
Get user by its active token for authentication middleware.
643689
"""
@@ -656,19 +702,7 @@ def get_current_user_by_token(
656702
is_token_active = objects[0][0]
657703
user = objects[0][1]
658704

659-
return is_token_active, data.UserResponse(
660-
user_id=user.id,
661-
username=user.username,
662-
first_name=user.first_name,
663-
last_name=user.last_name,
664-
email=user.email,
665-
normalized_email=user.normalized_email,
666-
verified=user.verified,
667-
created_at=user.created_at,
668-
updated_at=user.updated_at,
669-
autogenerated=user.autogenerated,
670-
application_id=user.application_id,
671-
)
705+
return is_token_active, user
672706

673707

674708
def get_current_user_with_groups_by_token(
@@ -859,8 +893,8 @@ def send_welcome_email(user: User, application_id: Optional[uuid.UUID] = None) -
859893
logger.info(
860894
f"Welcome email successfully submitted to Sendgrid for user with id={user_id}"
861895
)
862-
except Exception as e:
863-
logger.error(f"Error sending welcome email {e}")
896+
except Exception:
897+
logger.error(f"Error sending welcome email to {user.email}")
864898
pass
865899

866900

brood/api.py

Lines changed: 57 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
The Brood HTTP API
33
"""
44
import logging
5-
from typing import Any, Dict, List, Optional, Tuple
65
import uuid
6+
from typing import Any, Dict, List, Optional, Tuple
77

8+
import stripe # type: ignore
89
from fastapi import (
910
BackgroundTasks,
1011
Depends,
@@ -19,33 +20,29 @@
1920
)
2021
from fastapi.middleware.cors import CORSMiddleware
2122
from fastapi.security import OAuth2PasswordRequestForm
22-
import stripe # type: ignore
23+
from fastapi.security.utils import get_authorization_scheme_param
24+
from web3login.exceptions import MoonstreamVerificationError
2325

24-
from . import actions
25-
from . import data
26-
from . import exceptions
27-
from . import subscriptions
28-
from . import models
26+
from . import actions, data, exceptions, models, subscriptions
27+
from .db import yield_db_session_from_env
2928
from .middleware import (
30-
oauth2_scheme,
3129
autogenerated_user_token_check,
3230
get_current_user,
3331
get_current_user_with_groups,
3432
is_token_restricted,
3533
is_token_restricted_or_installation,
36-
get_current_user_or_installation,
34+
oauth2_scheme,
3735
)
38-
from .db import yield_db_session_from_env
39-
from .version import BROOD_VERSION
36+
from .resources.api import app as resources_api
4037
from .settings import (
41-
group_invite_link_from_env,
38+
DOCS_TARGET_PATH,
4239
ORIGINS,
43-
STRIPE_SIGNING_SECRET,
4440
REQUIRE_EMAIL_VERIFICATION,
4541
SEND_EMAIL_WELCOME,
46-
DOCS_TARGET_PATH,
42+
STRIPE_SIGNING_SECRET,
43+
group_invite_link_from_env,
4744
)
48-
from .resources.api import app as resources_api
45+
from .version import BROOD_VERSION
4946

5047
logging.basicConfig(level=logging.INFO)
5148
logger = logging.getLogger(__name__)
@@ -95,8 +92,9 @@ async def create_user_handler(
9592
request: Request,
9693
background_tasks: BackgroundTasks,
9794
username: str = Form(...),
98-
email: str = Form(...),
99-
password: str = Form(...),
95+
email: str = Form(None),
96+
password: str = Form(None),
97+
signature: str = Form(None),
10098
first_name: Optional[str] = Form(None),
10199
last_name: Optional[str] = Form(None),
102100
application_id: Optional[uuid.UUID] = Form(None),
@@ -106,26 +104,46 @@ async def create_user_handler(
106104
Create new user.
107105
108106
- **username** (string): Username
109-
- **email** (string): New user email
110-
- **password** (string): New user password
107+
- **email** (string, null): New user email
108+
- **password** (string, null): New user password
109+
- **signature** (string, null): User web3 signature in base64 format
111110
- **first_name** (string, null): User first name
112111
- **last_name** (string, null): User last name
113112
- **application_id** (uuid, null): External application user belongs to
114113
"""
115114
# If correct BOT_INSTALLATION_TOKEN_HEADER is provided with request it triggers
116115
# autogenerated user creation.
117116
autogenerated_user = autogenerated_user_token_check(request)
117+
if autogenerated_user and signature is not None:
118+
raise HTTPException(
119+
status_code=400,
120+
detail="Autogenerated user could not be created with web3 signature authorization",
121+
)
122+
123+
if signature is None:
124+
if email is None or password is None:
125+
raise HTTPException(
126+
status_code=422,
127+
detail="Signature or email with password should be specified",
128+
)
118129

119130
try:
120131
user = actions.create_user(
121-
db_session,
132+
session=db_session,
122133
username=username,
123134
email=email,
124135
password=password,
136+
signature=signature,
125137
autogenerated_user=autogenerated_user,
126138
first_name=first_name,
127139
last_name=last_name,
128140
application_id=application_id,
141+
is_verify=True if not REQUIRE_EMAIL_VERIFICATION else False,
142+
)
143+
except actions.UserInvalidParameters:
144+
raise HTTPException(
145+
status_code=422,
146+
detail="Provided wrong parameters for user creation",
129147
)
130148
except actions.UserAlreadyExists:
131149
raise HTTPException(
@@ -142,28 +160,27 @@ async def create_user_handler(
142160
status_code=422,
143161
detail=invalid_password_error.generic_error_message,
144162
)
145-
except Exception as e:
146-
logger.error(e)
163+
except MoonstreamVerificationError:
164+
raise HTTPException(status_code=400, detail="Invalid user signature")
165+
except Exception:
147166
raise HTTPException(status_code=500)
148167

149168
if autogenerated_user:
150169
return user
151170

152-
if SEND_EMAIL_WELCOME:
153-
background_tasks.add_task(
154-
actions.send_welcome_email,
155-
user=user,
156-
application_id=application_id,
157-
)
171+
if email is not None:
172+
if SEND_EMAIL_WELCOME:
173+
background_tasks.add_task(
174+
actions.send_welcome_email,
175+
user=user,
176+
application_id=application_id,
177+
)
158178

159-
if not REQUIRE_EMAIL_VERIFICATION:
160-
user.verified = True
161-
db_session.commit()
162-
return user
179+
if REQUIRE_EMAIL_VERIFICATION:
180+
background_tasks.add_task(
181+
actions.send_verification_email, session=db_session, user_id=user.id
182+
)
163183

164-
background_tasks.add_task(
165-
actions.send_verification_email, session=db_session, user_id=user.id
166-
)
167184
return user
168185

169186

@@ -240,7 +257,8 @@ async def create_token_restricted_handler(
240257

241258
@app.delete("/token", tags=["tokens"])
242259
async def delete_token_handler(
243-
oauth2: Tuple[uuid.UUID, str] = Depends(oauth2_scheme),
260+
request: Request,
261+
access_token: uuid.UUID = Depends(oauth2_scheme),
244262
target_token: Optional[uuid.UUID] = Form(None),
245263
db_session=Depends(yield_db_session_from_env),
246264
) -> uuid.UUID:
@@ -249,8 +267,9 @@ async def delete_token_handler(
249267
250268
- **target_token** (uuid, null): Token ID to revoke
251269
"""
252-
access_token = oauth2[0]
253-
scheme = oauth2[1]
270+
authorization: str = request.headers.get("Authorization")
271+
scheme_raw, _ = get_authorization_scheme_param(authorization)
272+
scheme = scheme_raw.lower()
254273
if scheme != "bearer":
255274
raise HTTPException(status_code=400, detail="Unaccepted scheme")
256275
try:
@@ -395,7 +414,6 @@ async def get_user_handler(
395414
@app.get("/user/find", tags=["users"], response_model=data.UserResponse)
396415
async def find_user_handler(
397416
token_restricted: bool = Depends(is_token_restricted_or_installation),
398-
_: Optional[models.User] = Depends(get_current_user_or_installation),
399417
username: Optional[str] = Query(None),
400418
email: Optional[str] = Query(None),
401419
user_id: Optional[uuid.UUID] = Query(None),

0 commit comments

Comments
 (0)