Skip to content
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
18 changes: 8 additions & 10 deletions app/auth/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,15 @@ 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',
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('new_password',
message='Passwords must match')])
submit = SubmitField('Update password')
EqualTo('password', message='Passwords must match')])
submit = SubmitField('Create account')
87 changes: 87 additions & 0 deletions app/auth/identity.py
Original file line number Diff line number Diff line change
@@ -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.")
101 changes: 73 additions & 28 deletions app/auth/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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:
Expand All @@ -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')
Expand All @@ -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():
Expand All @@ -45,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

Expand Down
1 change: 0 additions & 1 deletion app/manager/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down
7 changes: 0 additions & 7 deletions app/manager/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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'))
Expand Down
7 changes: 6 additions & 1 deletion app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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")
)
Expand Down
Empty file.
3 changes: 3 additions & 0 deletions app/templates/auth/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ <h1 class="text-2xl font-bold text-slate-800 mb-6 text-center">Sign In</h1>
Log In
</button>
</form>
<p class="mt-4 text-center text-sm text-slate-600">
<a href="{{ url_for('auth.register') }}" class="underline hover:text-slate-800">Create an account</a>
</p>
</div>
</div>
{% endblock %}
50 changes: 50 additions & 0 deletions app/templates/auth/register.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Create Account — AssistantScheduler{% endblock %}
{% block content %}
<div class="min-h-[60vh] flex items-center justify-center">
<div class="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md">
<h1 class="text-2xl font-bold text-slate-800 mb-2 text-center">Create Account</h1>
<p class="text-xs text-slate-500 mb-6 text-center">A manager must add your email before you can sign in.</p>
<form method="POST" class="space-y-4">
{{ form.hidden_tag() }}
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">First name</label>
{{ 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 %}
<p class="text-red-600 text-xs mt-1">{{ e }}</p>{% endfor %}
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Last name</label>
{{ 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 %}
<p class="text-red-600 text-xs mt-1">{{ e }}</p>{% endfor %}
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Email</label>
{{ 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 %}
<p class="text-red-600 text-xs mt-1">{{ e }}</p>{% endfor %}
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Password</label>
{{ 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 %}
<p class="text-red-600 text-xs mt-1">{{ e }}</p>{% endfor %}
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">Confirm password</label>
{{ 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 %}
<p class="text-red-600 text-xs mt-1">{{ e }}</p>{% endfor %}
</div>
<button type="submit"
class="mt-2 w-full bg-[#1e3a5f] hover:bg-[#2a5298] text-white font-semibold py-2 rounded-lg transition">
Create account
</button>
</form>
<p class="mt-4 text-center text-sm text-slate-600">
<a href="{{ url_for('auth.login') }}" class="underline hover:text-slate-800">Already have an account? Sign in</a>
</p>
</div>
</div>
{% endblock %}
13 changes: 4 additions & 9 deletions app/templates/manager/employee_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,10 @@ <h1 class="text-2xl font-bold text-slate-800 mb-6">{{ action }} Employee</h1>
<label class="block text-sm font-medium text-slate-700 mb-1">Role</label>
{{ 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") }}
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">
Password
{% if action == 'Edit' %}
<span class="text-slate-400 font-normal">(leave blank to keep current)</span>
{% endif %}
</label>
{{ 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") }}
</div>
<p class="text-xs text-slate-500">
No password is set here. The person signs in with their own account: they use
<strong>Create an account</strong> on the login page with this exact email, then sign in.
</p>
<div class="flex gap-3 pt-2">
{{ form.submit(class="bg-[#1e3a5f] hover:bg-[#2a5298] text-white text-sm font-semibold px-5 py-2 rounded-lg transition cursor-pointer") }}
<a href="{{ url_for('manager.employees') }}"
Expand Down
Loading
Loading