From 60bfd60b26091bd5176b06bf1a63cc546d20e8c2 Mon Sep 17 00:00:00 2001 From: SethTheMountain Date: Sun, 4 Oct 2026 16:06:53 -0400 Subject: [PATCH 1/2] Sign in through identity-service; link accounts by email; drop local passwords --- app/auth/forms.py | 11 +++ app/auth/identity.py | 87 +++++++++++++++++++ app/auth/routes.py | 72 ++++++++++++++- app/manager/forms.py | 1 - app/manager/routes.py | 7 -- app/models.py | 7 +- app/templates/auth/login.html | 3 + app/templates/auth/register.html | 50 +++++++++++ app/templates/manager/employee_form.html | 13 +-- .../25e648d4098e_add_identity_sub_to_users.py | 27 ++++++ requirements.txt | 7 ++ seed.py | 44 ++++------ seed_production.py | 29 +++---- tests/conftest.py | 14 +++ tests/test_auth_sso.py | 67 ++++++++++++++ 15 files changed, 372 insertions(+), 67 deletions(-) create mode 100644 app/auth/identity.py create mode 100644 app/templates/auth/register.html create mode 100644 migrations/versions/25e648d4098e_add_identity_sub_to_users.py create mode 100644 tests/test_auth_sso.py diff --git a/app/auth/forms.py b/app/auth/forms.py index b147fac..8cc7780 100644 --- a/app/auth/forms.py +++ b/app/auth/forms.py @@ -29,3 +29,14 @@ class ChangePasswordForm(FlaskForm): EqualTo('new_password', message='Passwords must match')]) submit = SubmitField('Update password') + + +class RegisterForm(FlaskForm): + first_name = StringField('First name', validators=[DataRequired(), Length(max=50)]) + last_name = StringField('Last name', validators=[DataRequired(), Length(max=50)]) + email = StringField('Email', validators=[DataRequired(), Email(), Length(max=150)]) + password = PasswordField('Password', validators=[DataRequired(), Length(min=8)]) + confirm = PasswordField('Confirm password', + validators=[DataRequired(), + EqualTo('password', message='Passwords must match')]) + submit = SubmitField('Create account') diff --git a/app/auth/identity.py b/app/auth/identity.py new file mode 100644 index 0000000..30d57e7 --- /dev/null +++ b/app/auth/identity.py @@ -0,0 +1,87 @@ +"""Client for the shared identity-service: sign-in, sign-up, and token verification.""" +import os +from dataclasses import dataclass + +import jwt +import requests +from jwt import PyJWKClient + +# identity-service runs on a free tier that sleeps; the first call can take a minute or more. +TIMEOUT_SECONDS = 90 + + +class IdentityError(Exception): + """Carries a message that is safe to show to the user.""" + + +class InvalidCredentials(IdentityError): + pass + + +@dataclass(frozen=True) +class Identity: + sub: str + email: str + + +def _base_url(): + return os.environ.get("IDENTITY_URL", "http://localhost:8081").rstrip("/") + + +_jwks_client = None +_jwks_client_url = None + + +def _jwks(): + global _jwks_client, _jwks_client_url + url = f"{_base_url()}/.well-known/jwks.json" + if _jwks_client is None or _jwks_client_url != url: + _jwks_client = PyJWKClient(url, cache_keys=True) + _jwks_client_url = url + return _jwks_client + + +def verify_access_token(token): + """Verify signature and expiry against identity-service's published keys; reject non-access tokens.""" + try: + key = _jwks().get_signing_key_from_jwt(token).key + claims = jwt.decode(token, key, algorithms=["RS256"], options={"require": ["exp", "sub"]}) + except jwt.PyJWTError as exc: + raise IdentityError("Your sign-in could not be verified. Please try again.") from exc + if claims.get("type") != "access": + raise IdentityError("Your sign-in could not be verified. Please try again.") + return Identity(sub=claims["sub"], email=(claims.get("email") or "").strip().lower()) + + +def _post(path, payload): + try: + return requests.post(f"{_base_url()}{path}", json=payload, timeout=TIMEOUT_SECONDS) + except requests.RequestException as exc: + raise IdentityError( + "Could not reach the sign-in service. It may be waking up, so try again in a minute." + ) from exc + + +def authenticate(email, password): + response = _post("/auth/login", {"email": email.strip(), "password": password}) + if response.status_code in (400, 401): + raise InvalidCredentials("Invalid email or password.") + if not response.ok: + raise IdentityError("Sign-in failed. Please try again.") + return verify_access_token(response.json()["accessToken"]) + + +def register(email, password, first_name, last_name): + """Create the identity-service account only. The caller signs in separately.""" + response = _post("/auth/register", { + "email": email.strip(), + "password": password, + "firstName": first_name.strip(), + "lastName": last_name.strip(), + }) + if response.status_code == 409: + raise IdentityError("An account with that email already exists. Try signing in instead.") + if response.status_code == 400: + raise IdentityError("That sign-up was rejected. Check your name, email and password (use a longer password).") + if not response.ok: + raise IdentityError("Could not create the account. Please try again.") diff --git a/app/auth/routes.py b/app/auth/routes.py index 65aca45..ec93ad1 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -3,10 +3,44 @@ from urllib.parse import urlparse from app import db from app.auth import auth -from app.auth.forms import LoginForm, ProfileForm, ChangePasswordForm +from app.auth import identity +from app.auth.forms import LoginForm, ProfileForm, ChangePasswordForm, RegisterForm from app.models import User +NOT_AUTHORIZED = ("This account isn't authorized to use AssistantScheduler. " + "Ask a manager to add your email.") + + +def _resolve_local_user(ident): + """Find the local user for a verified identity. Roles live here, never in the token.""" + user = User.query.filter_by(identity_sub=ident.sub).first() + if user is None and ident.email: + user = User.query.filter_by(email=ident.email).first() + if user is not None: + if user.identity_sub is not None: + return None # already linked to a different identity; never re-link + user.identity_sub = ident.sub + user.password_hash = None # the legacy hash is dead once SSO is linked + db.session.commit() + if user is None or user.role is None: + return None + return user + + +def _migrate_legacy_account(email, password): + """One-time: a not-yet-linked user whose old local password matches gets an identity account.""" + user = User.query.filter_by(email=email).first() + if user is None or user.identity_sub is not None or not user.check_password(password): + return None + first, _, last = user.name.strip().partition(' ') + try: + identity.register(email, password, first, last or first) + return identity.authenticate(email, password) + except identity.IdentityError: + return None + + @auth.route('/login', methods=['GET', 'POST']) def login(): if current_user.is_authenticated: @@ -16,9 +50,22 @@ def login(): form = LoginForm() if form.validate_on_submit(): - user = User.query.filter_by(email=form.email.data.lower()).first() - if user is None or not user.check_password(form.password.data): - flash('Invalid email or password.', 'danger') + email = form.email.data.strip().lower() + try: + try: + ident = identity.authenticate(email, form.password.data) + except identity.InvalidCredentials: + ident = _migrate_legacy_account(email, form.password.data) + if ident is None: + flash('Invalid email or password.', 'danger') + return redirect(url_for('auth.login')) + except identity.IdentityError as exc: + flash(str(exc), 'danger') + return redirect(url_for('auth.login')) + + user = _resolve_local_user(ident) + if user is None: + flash(NOT_AUTHORIZED, 'danger') return redirect(url_for('auth.login')) login_user(user) flash(f'Welcome back, {user.name}!', 'success') @@ -32,6 +79,23 @@ def login(): return render_template('auth/login.html', form=form) +@auth.route('/register', methods=['GET', 'POST']) +def register(): + if current_user.is_authenticated: + return redirect(url_for('auth.login')) + form = RegisterForm() + if form.validate_on_submit(): + try: + identity.register(form.email.data.strip().lower(), form.password.data, + form.first_name.data, form.last_name.data) + except identity.IdentityError as exc: + flash(str(exc), 'danger') + return render_template('auth/register.html', form=form) + flash('Account created. You can sign in once a manager has added your email.', 'success') + return redirect(url_for('auth.login')) + return render_template('auth/register.html', form=form) + + @auth.route('/logout') @login_required def logout(): diff --git a/app/manager/forms.py b/app/manager/forms.py index cd0fa6f..8c40e9e 100644 --- a/app/manager/forms.py +++ b/app/manager/forms.py @@ -38,7 +38,6 @@ class EmployeeForm(FlaskForm): Email(), Length(max=150) ]) - password = PasswordField('Password', validators=[Optional(), Length(min=6, max=150)]) role = SelectField('Role', coerce=int, validators=[DataRequired()]) submit = SubmitField('Save') diff --git a/app/manager/routes.py b/app/manager/routes.py index bd518b2..80ab5a3 100644 --- a/app/manager/routes.py +++ b/app/manager/routes.py @@ -70,16 +70,11 @@ def add_employee(): flash('An account with that email already exists.', 'danger') return render_template('manager/employee_form.html', form=form, action='Add') - if not form.password.data: - flash('Password is required for new employees.', 'danger') - return render_template('manager/employee_form.html', - form=form, action='Add') user = User( name=form.name.data, email=form.email.data.lower(), role_id=form.role.data, ) - user.set_password(form.password.data) db.session.add(user) db.session.commit() flash(f'{user.name} has been added.', 'success') @@ -103,8 +98,6 @@ def edit_employee(user_id): user.name = form.name.data user.email = form.email.data.lower() user.role_id = form.role.data - if form.password.data: - user.set_password(form.password.data) db.session.commit() flash(f'{user.name} has been updated.', 'success') return redirect(url_for('manager.employees')) diff --git a/app/models.py b/app/models.py index a5d2738..aa2eb1e 100644 --- a/app/models.py +++ b/app/models.py @@ -32,7 +32,10 @@ class User(UserMixin, db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False) email = db.Column(db.String(120), unique=True, nullable=False) - password_hash = db.Column(db.String(255), nullable=False) + # Legacy bcrypt hash. Cleared once the user's identity-service account is linked. + password_hash = db.Column(db.String(255), nullable=True) + # identity-service subject; set on the first successful sign-in by email. + identity_sub = db.Column(db.String(36), unique=True, nullable=True, index=True) role_id = db.Column(db.Integer, db.ForeignKey("roles.id"), nullable=True) location_id = db.Column(db.Integer, db.ForeignKey("locations.id"), nullable=True) @@ -50,6 +53,8 @@ def set_password(self, password): ).decode("utf-8") def check_password(self, password): + if not self.password_hash: + return False return bcrypt.checkpw( password.encode("utf-8"), self.password_hash.encode("utf-8") ) diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index 469ffb5..35e93a2 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -23,6 +23,9 @@

Sign In

Log In +

+ Create an account +

{% endblock %} diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html new file mode 100644 index 0000000..2391f78 --- /dev/null +++ b/app/templates/auth/register.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Create Account — AssistantScheduler{% endblock %} +{% block content %} +
+
+

Create Account

+

A manager must add your email before you can sign in.

+
+ {{ form.hidden_tag() }} +
+ + {{ form.first_name(class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-600") }} + {% for e in form.first_name.errors %} +

{{ e }}

{% endfor %} +
+
+ + {{ form.last_name(class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-600") }} + {% for e in form.last_name.errors %} +

{{ e }}

{% endfor %} +
+
+ + {{ form.email(class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-600") }} + {% for e in form.email.errors %} +

{{ e }}

{% endfor %} +
+
+ + {{ form.password(class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-600") }} + {% for e in form.password.errors %} +

{{ e }}

{% endfor %} +
+
+ + {{ form.confirm(class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-600") }} + {% for e in form.confirm.errors %} +

{{ e }}

{% endfor %} +
+ +
+

+ Already have an account? Sign in +

+
+
+{% endblock %} diff --git a/app/templates/manager/employee_form.html b/app/templates/manager/employee_form.html index 25344d8..b1981d8 100644 --- a/app/templates/manager/employee_form.html +++ b/app/templates/manager/employee_form.html @@ -20,15 +20,10 @@

{{ action }} Employee

{{ form.role(class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-600") }} -
- - {{ form.password(class="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-600") }} -
+

+ No password is set here. The person signs in with their own account: they use + Create an account on the login page with this exact email, then sign in. +

{{ form.submit(class="bg-[#1e3a5f] hover:bg-[#2a5298] text-white text-sm font-semibold px-5 py-2 rounded-lg transition cursor-pointer") }} Date: Sun, 4 Oct 2026 16:10:55 -0400 Subject: [PATCH 2/2] Remove local password change; profile no longer edits email --- app/auth/forms.py | 13 ----------- app/auth/routes.py | 31 +++++-------------------- app/templates/auth/change_password.html | 0 3 files changed, 6 insertions(+), 38 deletions(-) delete mode 100644 app/templates/auth/change_password.html diff --git a/app/auth/forms.py b/app/auth/forms.py index 8cc7780..54dd4e6 100644 --- a/app/auth/forms.py +++ b/app/auth/forms.py @@ -15,22 +15,9 @@ class LoginForm(FlaskForm): class ProfileForm(FlaskForm): name = StringField('Name', validators=[DataRequired(), Length(max=100)]) - email = StringField('Email', validators=[DataRequired(), Email()]) submit = SubmitField('Save') -class ChangePasswordForm(FlaskForm): - current_password = PasswordField('Current password', - validators=[DataRequired()]) - new_password = PasswordField('New password', - validators=[DataRequired(), Length(min=8)]) - confirm = PasswordField('Confirm new password', - validators=[DataRequired(), - EqualTo('new_password', - message='Passwords must match')]) - submit = SubmitField('Update password') - - class RegisterForm(FlaskForm): first_name = StringField('First name', validators=[DataRequired(), Length(max=50)]) last_name = StringField('Last name', validators=[DataRequired(), Length(max=50)]) diff --git a/app/auth/routes.py b/app/auth/routes.py index ec93ad1..1ca86b5 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -4,7 +4,7 @@ from app import db from app.auth import auth from app.auth import identity -from app.auth.forms import LoginForm, ProfileForm, ChangePasswordForm, RegisterForm +from app.auth.forms import LoginForm, ProfileForm, RegisterForm from app.models import User @@ -109,33 +109,14 @@ def logout(): def profile(): form = ProfileForm(obj=current_user) if form.validate_on_submit(): - existing = User.query.filter_by(email=form.email.data.lower()).first() - if existing and existing.id != current_user.id: - flash('That email is already in use.', 'danger') - else: - current_user.name = form.name.data - current_user.email = form.email.data.lower() - db.session.commit() - flash('Profile updated.', 'success') - return redirect(url_for('auth.profile')) + # Email is the link to the shared sign-in, so it is never edited here. + current_user.name = form.name.data + db.session.commit() + flash('Profile updated.', 'success') + return redirect(url_for('auth.profile')) return render_template('auth/profile.html', form=form) -@auth.route('/change-password', methods=['GET', 'POST']) -@login_required -def change_password(): - form = ChangePasswordForm() - if form.validate_on_submit(): - if not current_user.check_password(form.current_password.data): - flash('Current password is incorrect.', 'danger') - else: - current_user.set_password(form.new_password.data) - db.session.commit() - flash('Password updated.', 'success') - return redirect(url_for('auth.profile')) - return render_template('auth/change_password.html', form=form) - - from flask import redirect, url_for as _url_for from app.auth import auth as _auth diff --git a/app/templates/auth/change_password.html b/app/templates/auth/change_password.html deleted file mode 100644 index e69de29..0000000