Skip to content

Commit

Permalink
Password Login Fixes (#716)
Browse files Browse the repository at this point in the history
* Adjust /token to create a user if one doesn't exist. Only works with password auth.

* Add timezone to schedule for future-use (because we're collecting it right now..)

* Gate the first time registration for password auth behind an env var.
  • Loading branch information
MelissaAutumn authored Oct 11, 2024
1 parent eb48fe6 commit e907487
Show file tree
Hide file tree
Showing 13 changed files with 122 additions and 31 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ Check out the project's respective readmes:

This project uses [Fluent](https://projectfluent.org/) for localization. Files are located in their respective `l10n/<locale>/*.ftl`.

### Self-hosting

More information is coming soon! If you're adventurous follow the setup steps in each project. Once the project is running the first login will create a new user, any login attempts with new emails after that will check against existing credentials.

### Deployment

When changes are merged to main, a new [release](https://github.com/thunderbird/appointment/releases/) is cut, and the changes are deployed to [stage.appointment.day](https://stage.appointment.day/).
Expand Down
1 change: 1 addition & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ APP_ENV=dev
# List of comma separated admin usernames. USE WITH CAUTION! Those can do serious damage to the data.
APP_ADMIN_ALLOW_LIST=
APP_SETUP
APP_ALLOW_FIRST_TIME_REGISTER=

# -- FRONTEND --
FRONTEND_URL=http://localhost:8080
Expand Down
4 changes: 4 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ This is the backend component of Thunderbird Appointment written in Python using

More information will be provided in the future. There is currently a docker file provided which we use to deploy to AWS' ECS which should help you get started.

In order to create a user with password authentication mode, you will need to set `APP_ALLOW_FIRST_TIME_REGISTER=True` in your `.env`.

After the first login you'll want to fill the `APP_ADMIN_ALLOW_LIST` env variable with your account's email to access the basic admin panel located at `/admin/subscribers`.

### Configuration

The backend project uses dotenv files to inject environment variables into the application. A starting template can be found as [.env.example](.env.example). Copy that as your `.env` to get started.
Expand Down
1 change: 1 addition & 0 deletions backend/src/appointment/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ class Schedule(Base):
weekdays: str | dict = Column(JSON, default='[1,2,3,4,5]') # list of ISO weekdays, Mo-Su => 1-7
slot_duration: int = Column(Integer, default=30) # defaults to 30 minutes
booking_confirmation: bool = Column(Boolean, index=True, nullable=False, default=True)
timezone: str = Column(encrypted_type(String), index=True, nullable=True) # Not used right now but will be in the future

# What (if any) meeting link will we generate once the meeting is booked
meeting_link_provider: MeetingLinkProviderType = Column(
Expand Down
11 changes: 9 additions & 2 deletions backend/src/appointment/database/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"""

import json
import zoneinfo
from uuid import UUID
from datetime import datetime, date, time, timezone, timedelta
from typing import Annotated, Optional, Self
Expand Down Expand Up @@ -175,6 +176,7 @@ class ScheduleBase(BaseModel):
slot_duration: int | None = None
meeting_link_provider: MeetingLinkProviderType | None = MeetingLinkProviderType.none
booking_confirmation: bool = True
timezone: Optional[str] = None

class Config:
json_encoders = {
Expand Down Expand Up @@ -208,8 +210,13 @@ class ScheduleValidationIn(ScheduleBase):
def start_time_should_be_before_end_time(self) -> Self:
# Can't have the end time before the start time!
# (Well you can, it will roll over to the next day, but the ux is poor!)
start_time = datetime.combine(self.start_date, self.start_time, tzinfo=timezone.utc)
end_time = datetime.combine(self.start_date, self.end_time, tzinfo=timezone.utc)
# Note we have to convert to the local timezone for this to work...

# Fallback to utc...
tz = self.timezone or 'UTC'

start_time = datetime.combine(self.start_date, self.start_time, tzinfo=timezone.utc).astimezone(zoneinfo.ZoneInfo(tz))
end_time = datetime.combine(self.start_date, self.end_time, tzinfo=timezone.utc).astimezone(zoneinfo.ZoneInfo(tz))

start_time = (start_time + timedelta(minutes=self.slot_duration)).time()
end_time = end_time.time()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""add timezone to schedule
Revision ID: 502d0217a555
Revises: 01d80f00243f
Create Date: 2024-10-08 16:15:22.157158
"""
from alembic import op
import sqlalchemy as sa

from appointment.database.models import encrypted_type

# revision identifiers, used by Alembic.
revision = '502d0217a555'
down_revision = '01d80f00243f'
branch_labels = None
depends_on = None


def upgrade() -> None:
op.add_column('schedules', sa.Column('timezone', encrypted_type(sa.String), nullable=True, index=True))


def downgrade() -> None:
op.drop_column('schedules', 'timezone')
49 changes: 25 additions & 24 deletions backend/src/appointment/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from ..exceptions.fxa_api import NotInAllowListException
from ..l10n import l10n
from ..tasks.emails import send_confirm_email
from ..utils import get_password_hash

router = APIRouter()

Expand All @@ -46,6 +47,24 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None):
return encoded_jwt


def create_subscriber(db, email, password, timezone):
subscriber = repo.subscriber.create(db, schemas.SubscriberBase(
email=email,
username=email,
name=email.split('@')[0],
timezone=timezone
))

# Update with password
subscriber.password = get_password_hash(password)

db.add(subscriber)
db.commit()
db.refresh(subscriber)

return subscriber


@router.post('/can-login')
def can_login(
data: schemas.CheckEmail,
Expand Down Expand Up @@ -271,6 +290,12 @@ def token(
if os.getenv('AUTH_SCHEME') == 'fxa':
raise HTTPException(status_code=405)

has_subscribers = db.query(Subscriber).count()

if os.getenv('APP_ALLOW_FIRST_TIME_REGISTER') == 'True' and has_subscribers == 0:
# Create an initial subscriber based with the UTC timezone, the FTUE will give them a change to adjust this
create_subscriber(db, form_data.username, form_data.password, 'UTC')

"""Retrieve an access token from a given email (=username) and password."""
subscriber = repo.subscriber.get_by_email(db, form_data.username)
if not subscriber or subscriber.password is None:
Expand Down Expand Up @@ -347,27 +372,3 @@ def permission_check(subscriber: Subscriber = Depends(get_admin_subscriber)):
raise validation.InvalidPermissionLevelException()
return True # Covered by get_admin_subscriber


# @router.get('/test-create-account')
# def test_create_account(email: str, password: str, timezone: str, db: Session = Depends(get_db)):
# """Used to create a test account"""
# if os.getenv('APP_ENV') != 'dev':
# raise HTTPException(status_code=405)
# if os.getenv('AUTH_SCHEME') != 'password':
# raise HTTPException(status_code=405)
#
# subscriber = repo.subscriber.create(db, schemas.SubscriberBase(
# email=email,
# username=email,
# name=email.split('@')[0],
# timezone=timezone
# ))
#
# # Update with password
# subscriber.password = get_password_hash(password)
#
# db.add(subscriber)
# db.commit()
# db.refresh(subscriber)
#
# return subscriber
2 changes: 2 additions & 0 deletions backend/test/factory/schedule_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def _make_schedule(
meeting_link_provider=models.MeetingLinkProviderType.none,
slug=FAKER_RANDOM_VALUE,
booking_confirmation=True,
timezone='UTC'
):
with with_db() as db:
return repo.schedule.create(
Expand All @@ -52,6 +53,7 @@ def _make_schedule(
calendar_id=calendar_id
if factory_has_value(calendar_id)
else make_caldav_calendar(connected=True).id,
timezone=timezone,
),
)

Expand Down
42 changes: 42 additions & 0 deletions backend/test/integration/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,48 @@ def test_token(self, with_db, with_client, make_pro_subscriber):
)
assert response.status_code == 403, response.text

def test_token_creates_user(self, with_db, with_client):
with with_db() as db:
# Remove all subscribers
for sub in db.query(models.Subscriber).all():
db.delete(sub)
db.commit()

email = '[email protected]'
password = 'test'

email2 = '[email protected]'

# Disable first time registering
os.environ['APP_ALLOW_FIRST_TIME_REGISTER'] = ''

# Fails with improper env set
response = with_client.post(
'/token',
data={'username': email2, 'password': password},
)
assert response.status_code == 403, response.text

# Enable first time registering
os.environ['APP_ALLOW_FIRST_TIME_REGISTER'] = 'True'

# Test non-user credentials
response = with_client.post(
'/token',
data={'username': email, 'password': password},
)
assert response.status_code == 200, response.text
data = response.json()
assert data['access_token']
assert data['token_type'] == 'bearer'

# Test second non-user credentials
response = with_client.post(
'/token',
data={'username': email2, 'password': password},
)
assert response.status_code == 403, response.text


class TestFXA:
def test_fxa_login(self, with_client):
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/FTUE/SetupSchedule.vue
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ const onSubmit = async () => {
farthest_booking: 20160,
start_date: dj().format(DateFormatStrings.QalendarFullDay),
details: schedule.value?.details ?? '',
timezone: user.data.timezone,
};
const data = schedules.value.length > 0
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/ScheduleCreation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ const savingInProgress = ref(false);
const saveSchedule = async (withConfirmation = true) => {
savingInProgress.value = true;
// build data object for post request
const obj = { ...scheduleInput.value };
const obj = { ...scheduleInput.value, timezone: user.data.timezone };
// convert local input times to utc times
obj.start_time = dj(`${dj().format('YYYY-MM-DD')}T${obj.start_time}:00`)
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ export type FormExceptionDetail = {
status: number
}
export type PydanticException = {
detail?: FormExceptionDetail|PydanticExceptionDetail[];
detail?: string|FormExceptionDetail|PydanticExceptionDetail[];
}
export type Exception = {
status_code?: number;
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/views/LoginView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,15 @@ const handleFormError = (errObj: PydanticException) => {
const { detail } = errObj;
const fields = formRef.value.elements;
if (Array.isArray(detail)) {
if (Array.isArray(detail)) { // Pydantic errors
detail.forEach((err) => {
const name = err?.loc[1];
if (name) {
fields[name].setCustomValidity(err.ctx.reason);
}
});
} else if (typeof detail === 'string') { // HttpException errors are just strings
loginError.value = detail;
} else {
loginError.value = detail.message;
}
Expand Down Expand Up @@ -134,6 +136,7 @@ const login = async () => {
if (error?.value) {
// Handle error
handleFormError(canLogin.value as PydanticException);
isLoading.value = false;
return;
Expand Down Expand Up @@ -179,7 +182,7 @@ const login = async () => {
const { error }: Error = await user.login(call, email.value, password.value);
if (error) {
loginError.value = error as string;
handleFormError(error as PydanticException);
isLoading.value = false;
return;
}
Expand Down Expand Up @@ -233,7 +236,7 @@ const onEnter = () => {
<div class="form-body">
<form v-if="loginStep !== LoginSteps.SignUpConfirm" class="form" ref="formRef" autocomplete="off" @submit.prevent @keyup.enter="() => onEnter()">
<text-input name="email" v-model="email" :required="true">{{ t('login.form.email') }}</text-input>
<text-input v-if="isPasswordAuth" name="password" v-model="password" :required="true">{{ t('label.password') }}</text-input>
<text-input v-if="isPasswordAuth" name="password" v-model="password" :required="true" type="password">{{ t('label.password') }}</text-input>
<text-input v-if="loginStep === LoginSteps.SignUp && !hideInviteField" name="inviteCode" v-model="inviteCode" :help="t('login.form.no-invite-code')">{{ t('label.inviteCode') }}</text-input>
</form>
</div>
Expand Down

0 comments on commit e907487

Please sign in to comment.