Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Password Login Fixes #716

Merged
merged 3 commits into from
Oct 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 = 'greg@example.com'
password = 'test'

email2 = 'george@example.org'

# 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