feat: no invite codes and no email sign-in codes — that is the provider's job
Both were this app doing an identity provider's work. Sign-in and sign-up happen at sso.pedshub.com now: it takes the address, sends the code, checks it, and knows about second factors — none of which belongs here, and two of which were never done here at all. Gone: services/invites.py, services/login_codes.py, routers/login_code.py, the two models, the three admin invite routes, the invite_only flag and its switch, the invite field on both sign-up forms, and the code half of the sign-in page — which was the primary way in and is now a button that says "Sign in with PedsHub SSO". The password form stays for a site with no provider configured. Migration r7b8c9d0e1f2 drops invite_codes (three spent rows) and login_codes (empty). The dump beside it has both. 585 tests, and the contract snapshot is 320 routes — five fewer, all five named in the diff so the removal is reviewable rather than discovered later by a client. Also: "Make a deck" in the signed-in menu and on the landing page, going to the scribe's My Resources at app.pedshub.com/#resources. Same sign-in on both sides; the arrow says it leaves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
b6cfcaa1e9
commit
cf2f42975e
23 changed files with 107 additions and 1437 deletions
31
backend/alembic/versions/r7b8c9d0e1f2_drop_code_tables.py
Normal file
31
backend/alembic/versions/r7b8c9d0e1f2_drop_code_tables.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""Sign-up codes and email sign-in codes, gone.
|
||||||
|
|
||||||
|
Both were the app doing an identity provider's job. Sign-in is through
|
||||||
|
sso.pedshub.com now: it takes the email, sends the code, checks it, and knows
|
||||||
|
about second factors — none of which this app should be reimplementing, and
|
||||||
|
two of which it never did.
|
||||||
|
|
||||||
|
invite_codes held three rows, all of them spent or stale; login_codes held
|
||||||
|
none. The dump taken beside this change has both if anybody ever wants to know
|
||||||
|
who was invited.
|
||||||
|
|
||||||
|
Revision ID: r7b8c9d0e1f2
|
||||||
|
Revises: q6a7b8c9d0e1
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "r7b8c9d0e1f2"
|
||||||
|
down_revision = "q6a7b8c9d0e1"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.execute("DROP TABLE IF EXISTS login_codes")
|
||||||
|
op.execute("DROP TABLE IF EXISTS invite_codes")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# The tables can be recreated from the models in git history; their
|
||||||
|
# contents cannot, and inventing an invite is worse than not having one.
|
||||||
|
pass
|
||||||
|
|
@ -17,7 +17,6 @@ from app.routers import access
|
||||||
from app.routers import feedback
|
from app.routers import feedback
|
||||||
from app.routers import folders
|
from app.routers import folders
|
||||||
from app.routers import study_tools, uploads, articles, share, collections, study_plans, media, search, ai_mode, drafts, public
|
from app.routers import study_tools, uploads, articles, share, collections, study_plans, media, search, ai_mode, drafts, public
|
||||||
from app.routers import login_code
|
|
||||||
from app.utils.auth import get_password_hash
|
from app.utils.auth import get_password_hash
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -610,7 +609,6 @@ app.add_middleware(VersionAlias)
|
||||||
app.include_router(uploads.router)
|
app.include_router(uploads.router)
|
||||||
|
|
||||||
app.include_router(auth.router, prefix=f"{VERSIONED_ROOT}/auth", tags=["auth"])
|
app.include_router(auth.router, prefix=f"{VERSIONED_ROOT}/auth", tags=["auth"])
|
||||||
app.include_router(login_code.router, prefix=f"{VERSIONED_ROOT}/auth", tags=["auth"])
|
|
||||||
# Counts the landing page states about itself. No auth: a stranger reads it.
|
# Counts the landing page states about itself. No auth: a stranger reads it.
|
||||||
app.include_router(public.router, prefix=f"{VERSIONED_ROOT}/public", tags=["public"])
|
app.include_router(public.router, prefix=f"{VERSIONED_ROOT}/public", tags=["public"])
|
||||||
app.include_router(articles.router, prefix=f"{VERSIONED_ROOT}/articles", tags=["articles"])
|
app.include_router(articles.router, prefix=f"{VERSIONED_ROOT}/articles", tags=["articles"])
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,4 @@ __all__ = [
|
||||||
from app.models.feedback import ArticleFeedback, QuestionFeedback # noqa: F401
|
from app.models.feedback import ArticleFeedback, QuestionFeedback # noqa: F401
|
||||||
from app.models.folder import QuestionFolder, QuestionFolderQuestion # noqa: F401
|
from app.models.folder import QuestionFolder, QuestionFolderQuestion # noqa: F401
|
||||||
from app.models.user_note import ArticleSectionNote, QuestionNote # noqa: F401
|
from app.models.user_note import ArticleSectionNote, QuestionNote # noqa: F401
|
||||||
from app.models.invite import InviteCode # noqa: F401
|
|
||||||
from app.models.draft_question import DraftBatch, DraftQuestion # noqa: F401
|
from app.models.draft_question import DraftBatch, DraftQuestion # noqa: F401
|
||||||
from app.models.login_code import LoginCode # noqa: F401
|
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class InviteCode(Base):
|
|
||||||
"""A code an administrator issues so one person can register.
|
|
||||||
|
|
||||||
Single use by default: the point of invite-only is knowing who came in, and
|
|
||||||
a code that works forever is a password shared by everyone who has seen it.
|
|
||||||
A code is never deleted once used — who it let in is the record worth
|
|
||||||
keeping.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "invite_codes"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
code = Column(String(32), unique=True, nullable=False, index=True)
|
|
||||||
note = Column(String(200), nullable=True) # who it was meant for
|
|
||||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
#: Set when someone registers with it. Present means spent.
|
|
||||||
used_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
|
||||||
used_at = Column(DateTime, nullable=True)
|
|
||||||
#: An administrator can withdraw a code that has not been used.
|
|
||||||
revoked_at = Column(DateTime, nullable=True)
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class LoginCode(Base):
|
|
||||||
"""One issued sign-in code.
|
|
||||||
|
|
||||||
Only the fingerprint of the code is kept. A row here is a session waiting
|
|
||||||
to happen — anybody who could read the table could sign in as its owner —
|
|
||||||
so it holds something to compare against rather than something to type in.
|
|
||||||
|
|
||||||
The failed guesses are counted in this row and not in Redis. The rate
|
|
||||||
limiter fails open when the cache is down, which is the right call for
|
|
||||||
anything it normally protects; it is the wrong call for the only thing
|
|
||||||
standing between a patient stranger and six characters.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "login_codes"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
||||||
#: SHA-256 of the normalised code, hex. Not bcrypt: the guess budget is
|
|
||||||
#: what makes a short code safe, not the cost of testing one, and this is
|
|
||||||
#: compared on a path a person is waiting on. Not indexed and not unique
|
|
||||||
#: either — a code is only ever looked up against the one account it was
|
|
||||||
#: issued for, and six characters do collide.
|
|
||||||
code_hash = Column(String(64), nullable=False)
|
|
||||||
attempts = Column(Integer, nullable=False, default=0)
|
|
||||||
expires_at = Column(DateTime, nullable=False)
|
|
||||||
#: Set when the code is spent, when its guesses run out, and when a later
|
|
||||||
#: request retires it. All three mean the same thing to every reader here:
|
|
||||||
#: no longer usable.
|
|
||||||
consumed_at = Column(DateTime, nullable=True)
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
|
|
@ -13,8 +13,7 @@ from app.config import settings
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.ai_model_config import AIModelConfig
|
from app.models.ai_model_config import AIModelConfig
|
||||||
from app.models.invite import InviteCode
|
from app.services import ai_service, site_settings, tts_voices
|
||||||
from app.services import ai_service, invites, site_settings, tts_voices
|
|
||||||
from app.schemas.auth import UserResponse, UserUpdateRole, UserCreate
|
from app.schemas.auth import UserResponse, UserUpdateRole, UserCreate
|
||||||
from app.schemas.admin import AIModelConfigCreate, AIModelConfigResponse, AIModelConfigUpdate
|
from app.schemas.admin import AIModelConfigCreate, AIModelConfigResponse, AIModelConfigUpdate
|
||||||
from app.utils.auth import require_admin, get_current_user, get_password_hash
|
from app.utils.auth import require_admin, get_current_user, get_password_hash
|
||||||
|
|
@ -560,50 +559,6 @@ def search_tts_voices(
|
||||||
raise HTTPException(status_code=400, detail=f"Unknown provider '{provider}'. Valid: litellm")
|
raise HTTPException(status_code=400, detail=f"Unknown provider '{provider}'. Valid: litellm")
|
||||||
|
|
||||||
|
|
||||||
# --- Invite codes ---
|
|
||||||
|
|
||||||
class InviteIn(BaseModel):
|
|
||||||
note: str | None = Field(default=None, max_length=200)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/invites")
|
|
||||||
def list_invites(db: Session = Depends(get_db), admin: User = Depends(require_admin)):
|
|
||||||
"""Every code, newest first, with who it let in."""
|
|
||||||
rows = db.query(InviteCode).order_by(InviteCode.created_at.desc()).limit(200).all()
|
|
||||||
users = {u.id: u for u in db.query(User).filter(
|
|
||||||
User.id.in_({r.used_by for r in rows if r.used_by}))} if rows else {}
|
|
||||||
return [invites.as_json(row, users) for row in rows]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/invites", status_code=201)
|
|
||||||
def create_invite(data: InviteIn, db: Session = Depends(get_db),
|
|
||||||
admin: User = Depends(require_admin)):
|
|
||||||
row = invites.create(db, created_by=admin.id, note=data.note)
|
|
||||||
return invites.as_json(row, {})
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/invites/{invite_id}", status_code=204)
|
|
||||||
def revoke_invite(invite_id: int, db: Session = Depends(get_db),
|
|
||||||
admin: User = Depends(require_admin)):
|
|
||||||
"""Withdraw an unused code, or clear away a spent one.
|
|
||||||
|
|
||||||
An unused code is withdrawn — it stays listed, so it is clear that it was
|
|
||||||
issued and then stopped. A spent or already-withdrawn code has nothing left
|
|
||||||
to stop, and a list that only grows is a list nobody reads; removing it
|
|
||||||
loses who it let in, but that person has an account, which is the record
|
|
||||||
that matters.
|
|
||||||
"""
|
|
||||||
row = db.get(InviteCode, invite_id)
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(404, "Invite not found")
|
|
||||||
if row.used_by is not None or row.revoked_at is not None:
|
|
||||||
db.delete(row)
|
|
||||||
db.commit()
|
|
||||||
return
|
|
||||||
row.revoked_at = datetime.utcnow()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
# --- System Settings ---
|
# --- System Settings ---
|
||||||
|
|
||||||
@router.get("/settings")
|
@router.get("/settings")
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from datetime import datetime, timedelta
|
||||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request
|
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.services import captcha, invites, refresh_tokens, site_settings
|
from app.services import captcha, refresh_tokens, site_settings
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.email_verification import EmailVerification
|
from app.models.email_verification import EmailVerification
|
||||||
|
|
@ -116,19 +116,18 @@ def signup_policy(db: Session = Depends(get_db)):
|
||||||
# The very first account is always allowed, or a new install could lock
|
# The very first account is always allowed, or a new install could lock
|
||||||
# itself out before an administrator exists to issue a code.
|
# itself out before an administrator exists to issue a code.
|
||||||
if db.query(User).count() == 0:
|
if db.query(User).count() == 0:
|
||||||
return {"invite_required": False, "first_user": True, "registration_open": True}
|
return {"first_user": True, "registration_open": True}
|
||||||
# Whether anyone may join at all, so the sign-in page can stop offering a
|
# Whether anyone may join at all, so the sign-in page can stop offering a
|
||||||
# door that is locked. It was offering one: registration can be turned off
|
# door that is locked. It was offering one: registration can be turned off
|
||||||
# site-wide, and the only way to find out was to fill the form in and be
|
# site-wide, and the only way to find out was to fill the form in and be
|
||||||
# refused.
|
# refused.
|
||||||
# Single sign-on closes the password door, and this is the one question
|
# Single sign-on closes the password door, and this is the one question
|
||||||
# the sign-up form already asks. Saying so here means the form can decline
|
# the sign-up form already asks. Saying so here means the form can decline
|
||||||
# to draw itself rather than collect a name, an email, a password twice and
|
# to draw itself rather than collect a name, an email and a password
|
||||||
# an invite code, and then be refused by the POST.
|
# twice, and then be refused by the POST.
|
||||||
from app.config import settings as cfg
|
from app.config import settings as cfg
|
||||||
sso_only = _get_sso_settings()["sso_only"]
|
sso_only = _get_sso_settings()["sso_only"]
|
||||||
return {"invite_required": site_settings.get_flag("invite_only"),
|
return {"first_user": False,
|
||||||
"first_user": False,
|
|
||||||
"sso_only": sso_only,
|
"sso_only": sso_only,
|
||||||
# So the page can name the provider rather than say "single
|
# So the page can name the provider rather than say "single
|
||||||
# sign-on" at somebody who only knows it as PedsHub SSO.
|
# sign-on" at somebody who only knows it as PedsHub SSO.
|
||||||
|
|
@ -161,14 +160,6 @@ async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import logging; logging.getLogger(__name__).warning(f"Redis registration check failed (failing open): {e}")
|
import logging; logging.getLogger(__name__).warning(f"Redis registration check failed (failing open): {e}")
|
||||||
|
|
||||||
# Invite-only: a code is checked before anything is created, and spent only
|
|
||||||
# once the account exists, so a failure part-way through does not burn it.
|
|
||||||
invite = None
|
|
||||||
if not is_first_user and site_settings.get_flag("invite_only"):
|
|
||||||
invite = invites.usable(db, user_data.invite_code)
|
|
||||||
if invite is None:
|
|
||||||
raise HTTPException(403, "This site is invite-only. A valid invite code is required.")
|
|
||||||
|
|
||||||
if len(user_data.password) < 8:
|
if len(user_data.password) < 8:
|
||||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||||
|
|
||||||
|
|
@ -195,8 +186,6 @@ async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db:
|
||||||
)
|
)
|
||||||
db.add(verification)
|
db.add(verification)
|
||||||
db.commit()
|
db.commit()
|
||||||
if invite is not None:
|
|
||||||
invites.spend(db, invite, user)
|
|
||||||
db.refresh(user)
|
db.refresh(user)
|
||||||
|
|
||||||
if is_first_user:
|
if is_first_user:
|
||||||
|
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
"""Ask for a sign-in code, and type one in.
|
|
||||||
|
|
||||||
Kept out of the auth router because these two routes answer to a rule the rest
|
|
||||||
of that file does not: whatever the state of the address given, the reply is
|
|
||||||
the same reply, and takes the same time to arrive. That is easy to break by
|
|
||||||
adding an early return next to routes that quite reasonably have several.
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.models.email_verification import EmailVerification
|
|
||||||
from app.models.user import User
|
|
||||||
from app.schemas.auth import LoginCodeRequest, LoginCodeVerify, Token
|
|
||||||
from app.services import email_service, login_codes, site_settings
|
|
||||||
from app.utils.auth import check_rate_limit, create_access_token
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
#: The only thing the request endpoint ever says. Not "we sent you a code" and
|
|
||||||
#: not "no such account": either would turn the form into a way to ask the site
|
|
||||||
#: whether a given person is a member of it.
|
|
||||||
SENT = "If that address has an account, a sign-in code is on its way."
|
|
||||||
|
|
||||||
#: And the only thing the verify endpoint says when it will not let somebody
|
|
||||||
#: in. Wrong code, expired code, code out of guesses, address with no account —
|
|
||||||
#: one answer, because the alternative is a way to sort guesses into warm and
|
|
||||||
#: cold.
|
|
||||||
REFUSED = "That code is not right, or it has expired. Request a new one."
|
|
||||||
|
|
||||||
#: Wording and status are identical whichever way it went — but the work is
|
|
||||||
#: not, and a stopwatch reads the difference. Every reply from both routes is
|
|
||||||
#: held to this floor, comfortably above what either costs. Sending the mail is
|
|
||||||
#: a background task and runs after the response, so it never shows up in the
|
|
||||||
#: timing at all.
|
|
||||||
MIN_SECONDS = 0.4
|
|
||||||
|
|
||||||
#: Three per quarter hour, per address. This is the limit that matters when
|
|
||||||
#: issuing: the address is who the mail lands on, and the one thing an attacker
|
|
||||||
#: cannot vary while still reaching the person they mean to reach.
|
|
||||||
PER_ADDRESS_CALLS, PER_ADDRESS_WINDOW = 3, 15 * 60
|
|
||||||
|
|
||||||
#: Twenty per hour, per source address. Deliberately the looser of the two: a
|
|
||||||
#: hospital or university puts a whole department behind one address, so a
|
|
||||||
#: tight limit here locks out real people and barely inconveniences anyone
|
|
||||||
#: renting a hundred addresses. It protects the site's sending reputation from
|
|
||||||
#: one machine working through a word list; the per-address limit is what
|
|
||||||
#: protects any individual mailbox.
|
|
||||||
PER_IP_CALLS, PER_IP_WINDOW = 20, 60 * 60
|
|
||||||
|
|
||||||
#: Ten guesses a quarter hour against one address, on top of the five any one
|
|
||||||
#: code will answer. The code's own budget is the harder bound and survives the
|
|
||||||
#: cache being down; this one bounds what somebody gets by burning through
|
|
||||||
#: codes — three issued in a window, five guesses each, would otherwise be
|
|
||||||
#: fifteen. It is keyed by address rather than by network because a code only
|
|
||||||
#: means anything against the address it was issued for: limiting the network
|
|
||||||
#: would lock out a shared one while an attacker simply moved to the next
|
|
||||||
#: address.
|
|
||||||
PER_ADDRESS_TRIES, PER_ADDRESS_TRIES_WINDOW = 10, 15 * 60
|
|
||||||
|
|
||||||
|
|
||||||
def _sso_only() -> bool:
|
|
||||||
return site_settings.get_flag("sso_only")
|
|
||||||
|
|
||||||
|
|
||||||
async def _hold(started: float):
|
|
||||||
elapsed = time.perf_counter() - started
|
|
||||||
if elapsed < MIN_SECONDS:
|
|
||||||
await asyncio.sleep(MIN_SECONDS - elapsed)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login-code")
|
|
||||||
async def request_login_code(
|
|
||||||
data: LoginCodeRequest,
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
request: Request = None,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Send a sign-in code to an address, if that address has an account."""
|
|
||||||
started = time.perf_counter()
|
|
||||||
|
|
||||||
if _sso_only():
|
|
||||||
raise HTTPException(status_code=403, detail="Email sign-in is disabled. Please use SSO.")
|
|
||||||
|
|
||||||
address = data.email.lower().strip()
|
|
||||||
# Both limits are checked before the account is looked up, so that being
|
|
||||||
# throttled cannot itself become the tell about whether an account exists.
|
|
||||||
# The address is keyed by its fingerprint: Redis is a cache with a rather
|
|
||||||
# more relaxed life than the database, and it has no business holding a
|
|
||||||
# readable list of who has an account here.
|
|
||||||
check_rate_limit(
|
|
||||||
key=f"login_code:addr:{login_codes.fingerprint(address)}",
|
|
||||||
max_calls=PER_ADDRESS_CALLS,
|
|
||||||
window_seconds=PER_ADDRESS_WINDOW,
|
|
||||||
detail="Too many sign-in codes requested for that address. Please wait a few minutes.",
|
|
||||||
)
|
|
||||||
client_ip = (request.client.host if request and request.client else "unknown")
|
|
||||||
check_rate_limit(
|
|
||||||
key=f"login_code:ip:{client_ip}",
|
|
||||||
max_calls=PER_IP_CALLS,
|
|
||||||
window_seconds=PER_IP_WINDOW,
|
|
||||||
detail="Too many sign-in codes requested from this network. Please wait a while.",
|
|
||||||
)
|
|
||||||
|
|
||||||
user = db.query(User).filter(User.email == address).first()
|
|
||||||
if user is not None:
|
|
||||||
code = login_codes.issue(db, user)
|
|
||||||
background_tasks.add_task(email_service.send_login_code_email,
|
|
||||||
user.email, user.name, login_codes.for_display(code))
|
|
||||||
|
|
||||||
await _hold(started)
|
|
||||||
return {"message": SENT}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login-code/verify", response_model=Token)
|
|
||||||
async def verify_login_code(data: LoginCodeVerify, db: Session = Depends(get_db)):
|
|
||||||
"""Spend a code and hand back a session.
|
|
||||||
|
|
||||||
The code is typed into the page that asked for it, so nothing has to travel
|
|
||||||
between devices, no credential ends up in a URL, and there is no history
|
|
||||||
entry to clear afterwards.
|
|
||||||
"""
|
|
||||||
started = time.perf_counter()
|
|
||||||
|
|
||||||
if _sso_only():
|
|
||||||
raise HTTPException(status_code=403, detail="Email sign-in is disabled. Please use SSO.")
|
|
||||||
|
|
||||||
address = data.email.lower().strip()
|
|
||||||
# Before the lookup, and counted for an address with no account exactly as
|
|
||||||
# for one with — otherwise the 429 says which is which.
|
|
||||||
check_rate_limit(
|
|
||||||
key=f"login_code:try:{login_codes.fingerprint(address)}",
|
|
||||||
max_calls=PER_ADDRESS_TRIES,
|
|
||||||
window_seconds=PER_ADDRESS_TRIES_WINDOW,
|
|
||||||
detail="Too many attempts for that address. Please wait a few minutes and request a new code.",
|
|
||||||
)
|
|
||||||
|
|
||||||
user = login_codes.verify(db, address, data.code)
|
|
||||||
if user is None:
|
|
||||||
await _hold(started)
|
|
||||||
raise HTTPException(status_code=400, detail=REFUSED)
|
|
||||||
|
|
||||||
# Checked after the code is spent, and deliberately: a refusal must not
|
|
||||||
# leave a usable credential behind.
|
|
||||||
#
|
|
||||||
# Reading a code out of the mailbox proves control of it, which is exactly
|
|
||||||
# what verification checks — and it is still not allowed to stand in for
|
|
||||||
# it. Somebody can register an address that is not theirs, choose a
|
|
||||||
# password and never verify; if the real owner's sign-in code marked that
|
|
||||||
# account verified, they would be signing in to an account the squatter
|
|
||||||
# already has the password to. The verification mail is in the same inbox.
|
|
||||||
verification = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first()
|
|
||||||
if verification and verification.verified_at is None:
|
|
||||||
await _hold(started)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="Email not verified. Please check your inbox and verify your email before signing in.",
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("Sign-in code spent by user %s", user.id)
|
|
||||||
await _hold(started)
|
|
||||||
return Token(access_token=create_access_token(data={"sub": user.email}))
|
|
||||||
|
|
@ -9,8 +9,6 @@ class UserCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
#: Solved hCaptcha challenge. Absent when the site has no secret configured.
|
#: Solved hCaptcha challenge. Absent when the site has no secret configured.
|
||||||
captcha_token: str | None = None
|
captcha_token: str | None = None
|
||||||
#: Required only while the site is invite-only.
|
|
||||||
invite_code: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
class UserResponse(BaseModel):
|
||||||
|
|
@ -101,12 +99,3 @@ class SsoExchangeRequest(BaseModel):
|
||||||
code: str = Field(min_length=8, max_length=200)
|
code: str = Field(min_length=8, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
class LoginCodeRequest(BaseModel):
|
|
||||||
email: EmailStr
|
|
||||||
|
|
||||||
|
|
||||||
class LoginCodeVerify(BaseModel):
|
|
||||||
email: EmailStr
|
|
||||||
#: Typed by a person, so it arrives in whatever shape they typed it and is
|
|
||||||
#: normalised before it is compared.
|
|
||||||
code: str
|
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
"""Invite codes: issuing them, checking one, and spending it.
|
|
||||||
|
|
||||||
Kept out of the routers because two of them need it — registration spends a
|
|
||||||
code, administration issues them — and the rule for "usable" is the kind of
|
|
||||||
thing that must have exactly one definition.
|
|
||||||
"""
|
|
||||||
import secrets
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.models.invite import InviteCode
|
|
||||||
from app.models.user import User
|
|
||||||
|
|
||||||
#: Unambiguous when read aloud or copied: no O/0, no I/1/l.
|
|
||||||
ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
||||||
LENGTH = 10
|
|
||||||
|
|
||||||
|
|
||||||
def generate_code() -> str:
|
|
||||||
return "".join(secrets.choice(ALPHABET) for _ in range(LENGTH))
|
|
||||||
|
|
||||||
|
|
||||||
def create(db: Session, created_by: int | None, note: str | None = None) -> InviteCode:
|
|
||||||
# Retried rather than trusted: the column is unique, and a collision is
|
|
||||||
# cheaper to avoid than to explain.
|
|
||||||
for _ in range(5):
|
|
||||||
code = generate_code()
|
|
||||||
if not db.query(InviteCode.id).filter(InviteCode.code == code).first():
|
|
||||||
row = InviteCode(code=code, note=(note or None), created_by=created_by)
|
|
||||||
db.add(row)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(row)
|
|
||||||
return row
|
|
||||||
raise RuntimeError("Could not allocate an unused invite code")
|
|
||||||
|
|
||||||
|
|
||||||
def usable(db: Session, code: str | None) -> InviteCode | None:
|
|
||||||
"""The code, if it exists and has neither been spent nor withdrawn."""
|
|
||||||
cleaned = (code or "").strip().upper()
|
|
||||||
if not cleaned:
|
|
||||||
return None
|
|
||||||
row = db.query(InviteCode).filter(InviteCode.code == cleaned).first()
|
|
||||||
if row is None or row.used_by is not None or row.revoked_at is not None:
|
|
||||||
return None
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
def spend(db: Session, invite: InviteCode, user: User) -> None:
|
|
||||||
invite.used_by = user.id
|
|
||||||
invite.used_at = datetime.utcnow()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def as_json(row: InviteCode, users: dict[int, User]) -> dict:
|
|
||||||
used_by = users.get(row.used_by)
|
|
||||||
return {
|
|
||||||
"id": row.id,
|
|
||||||
"code": row.code,
|
|
||||||
"note": row.note,
|
|
||||||
"created_at": row.created_at,
|
|
||||||
"used_at": row.used_at,
|
|
||||||
"used_by_name": getattr(used_by, "name", None),
|
|
||||||
"used_by_email": getattr(used_by, "email", None),
|
|
||||||
"revoked_at": row.revoked_at,
|
|
||||||
"status": "used" if row.used_by else "revoked" if row.revoked_at else "open",
|
|
||||||
}
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
"""Signing in with a code sent to your own mailbox.
|
|
||||||
|
|
||||||
Six characters a person reads off an email and types into the page they asked
|
|
||||||
from. Everything here follows from the code being short: it can be guessed, so
|
|
||||||
what makes it safe is not its length on its own but the arithmetic between its
|
|
||||||
length, how many guesses it will ever answer, and how long it lives.
|
|
||||||
"""
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import re
|
|
||||||
import secrets
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.models.login_code import LoginCode
|
|
||||||
from app.models.user import User
|
|
||||||
#: The house alphabet, and the reason it is the house alphabet: no O/0 and no
|
|
||||||
#: I/1, because these are read off a screen and typed back. Borrowed rather
|
|
||||||
#: than copied — there should be one answer to "which characters may a person
|
|
||||||
#: be asked to retype", not one per feature.
|
|
||||||
from app.services.invites import ALPHABET
|
|
||||||
|
|
||||||
#: Six characters of a 32-letter alphabet: 2^30 codes, where the six digits
|
|
||||||
#: everyone is used to would be 2^20. The length is only half the argument
|
|
||||||
#: though, and the smaller half. A code answers at most MAX_ATTEMPTS guesses
|
|
||||||
#: before it is retired, only one is ever live per person, and the address may
|
|
||||||
#: only be tried so often — so an attacker gets tens of guesses an hour against
|
|
||||||
#: a billion, while sending the victim a mail for every code they burn through.
|
|
||||||
#: Eight characters would buy a thousandfold against an attack the guess budget
|
|
||||||
#: has already ended, and cost every user two more characters to type.
|
|
||||||
LENGTH = 6
|
|
||||||
|
|
||||||
#: Five. Enough that a mistyped character, a stale code from a first attempt
|
|
||||||
#: and one more fumble do not lock somebody out of their own account; few
|
|
||||||
#: enough that the whole budget is nothing against 2^30. Counted in the row
|
|
||||||
#: rather than the cache — see the model.
|
|
||||||
MAX_ATTEMPTS = 5
|
|
||||||
|
|
||||||
#: Fifteen minutes, unchanged from when this was a link, and for a reason that
|
|
||||||
#: survived the change: a first mail between a sender and a recipient who have
|
|
||||||
#: never corresponded is routinely greylisted five to ten minutes, and a code
|
|
||||||
#: that has expired before it arrives is not a sign-in method. Shortening it
|
|
||||||
#: would not slow an attacker down — only one code is ever live per person and
|
|
||||||
#: it answers five guesses however long it sits there — so the clock is a
|
|
||||||
#: deliverability question, and the one thing it does buy is a shorter window
|
|
||||||
#: for a mailbox somebody else is reading. Fifteen is where those meet.
|
|
||||||
CODE_TTL = timedelta(minutes=15)
|
|
||||||
|
|
||||||
#: Rows are dead weight once well past expiry and nothing reads them. Kept a
|
|
||||||
#: day rather than deleted on the spot, so "it did not work this morning" can
|
|
||||||
#: still be answered from the table.
|
|
||||||
KEEP_SPENT_FOR = timedelta(days=1)
|
|
||||||
|
|
||||||
|
|
||||||
def normalise(code: str) -> str:
|
|
||||||
"""What the person meant, from what they typed.
|
|
||||||
|
|
||||||
Case, spaces and dashes are not part of the code — the mail shows it in two
|
|
||||||
groups of three, and somebody will type the space. Anything that is not a
|
|
||||||
letter or a digit goes; the rest is upper-cased. There is no look-alike
|
|
||||||
folding to do, which is the point of that alphabet: O and 0 are not two
|
|
||||||
characters one might mistake for each other here, they are both simply not
|
|
||||||
characters a code can contain.
|
|
||||||
"""
|
|
||||||
return re.sub(r"[^A-Z0-9]", "", (code or "").upper())
|
|
||||||
|
|
||||||
|
|
||||||
def fingerprint(value: str) -> str:
|
|
||||||
return hashlib.sha256(value.strip().encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def generate_code() -> str:
|
|
||||||
return "".join(secrets.choice(ALPHABET) for _ in range(LENGTH))
|
|
||||||
|
|
||||||
|
|
||||||
def for_display(code: str) -> str:
|
|
||||||
"""Grouped for reading aloud in one's head. Typed back in any shape."""
|
|
||||||
return f"{code[:3]} {code[3:]}" if len(code) == 6 else code
|
|
||||||
|
|
||||||
|
|
||||||
def issue(db: Session, user: User) -> str:
|
|
||||||
"""Retire this user's outstanding codes, mint a new one, return it.
|
|
||||||
|
|
||||||
Superseding rather than accumulating, and it matters more here than it did
|
|
||||||
for a link: with several codes live at once, a guess is tested against all
|
|
||||||
of them, and a six-character code gets easier to hit with every one left
|
|
||||||
lying around. Exactly one is live per person at any moment.
|
|
||||||
"""
|
|
||||||
now = datetime.utcnow()
|
|
||||||
db.query(LoginCode).filter(
|
|
||||||
LoginCode.user_id == user.id,
|
|
||||||
LoginCode.consumed_at.is_(None),
|
|
||||||
).update({"consumed_at": now}, synchronize_session=False)
|
|
||||||
db.query(LoginCode).filter(
|
|
||||||
LoginCode.user_id == user.id,
|
|
||||||
LoginCode.expires_at < now - KEEP_SPENT_FOR,
|
|
||||||
).delete(synchronize_session=False)
|
|
||||||
|
|
||||||
code = generate_code()
|
|
||||||
db.add(LoginCode(user_id=user.id, code_hash=fingerprint(code), expires_at=now + CODE_TTL))
|
|
||||||
db.commit()
|
|
||||||
return code
|
|
||||||
|
|
||||||
|
|
||||||
def verify(db: Session, email: str, code: str) -> User | None:
|
|
||||||
"""Spend a code and return whose it is, or None if it is no good.
|
|
||||||
|
|
||||||
Scoped to the address, and that is not merely convenient. A code looked up
|
|
||||||
on its own would be tested against every code live on the site at once, so
|
|
||||||
each guess would be as many guesses as there are people signing in — the
|
|
||||||
short code's one real weakness, closed by knowing whose code it is meant to
|
|
||||||
be before comparing.
|
|
||||||
|
|
||||||
One answer for every failure: wrong code, expired code, code whose guesses
|
|
||||||
have run out, address with no account. Telling them apart would say which
|
|
||||||
of those was true, and none of them leaves the person anything to do but
|
|
||||||
ask for a new code.
|
|
||||||
"""
|
|
||||||
user = db.query(User).filter(User.email == email.lower().strip()).first()
|
|
||||||
if user is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
row = db.query(LoginCode).filter(
|
|
||||||
LoginCode.user_id == user.id,
|
|
||||||
LoginCode.consumed_at.is_(None),
|
|
||||||
).order_by(LoginCode.id.desc()).first()
|
|
||||||
if row is None or row.expires_at <= datetime.utcnow():
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not hmac.compare_digest(row.code_hash, fingerprint(normalise(code))):
|
|
||||||
# A wrong guess costs one of five, and the fifth ends the code rather
|
|
||||||
# than leaving it to be guessed at a slower rate: whoever is typing has
|
|
||||||
# either lost the mail or is not its owner, and both are one click from
|
|
||||||
# a new code. The count is a SQL expression so that two guesses racing
|
|
||||||
# each other cost two.
|
|
||||||
exhausted = row.attempts + 1 >= MAX_ATTEMPTS
|
|
||||||
spend = {"attempts": LoginCode.attempts + 1}
|
|
||||||
if exhausted:
|
|
||||||
spend["consumed_at"] = datetime.utcnow()
|
|
||||||
db.query(LoginCode).filter(
|
|
||||||
LoginCode.id == row.id,
|
|
||||||
LoginCode.consumed_at.is_(None),
|
|
||||||
).update(spend, synchronize_session=False)
|
|
||||||
db.commit()
|
|
||||||
return None
|
|
||||||
|
|
||||||
# The claim is the UPDATE, not the read above: two requests carrying the
|
|
||||||
# same code both pass the comparison, and only the one whose UPDATE matched
|
|
||||||
# a row still unspent gets to sign in.
|
|
||||||
claimed = db.query(LoginCode).filter(
|
|
||||||
LoginCode.id == row.id,
|
|
||||||
LoginCode.consumed_at.is_(None),
|
|
||||||
).update({"consumed_at": datetime.utcnow()}, synchronize_session=False)
|
|
||||||
db.commit()
|
|
||||||
if not claimed:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
@ -19,8 +19,6 @@ FLAGS: dict[str, bool] = {
|
||||||
"registration_enabled": True,
|
"registration_enabled": True,
|
||||||
#: Whether a learner may create a public share link for a session.
|
#: Whether a learner may create a public share link for a session.
|
||||||
"sharing_enabled": True,
|
"sharing_enabled": True,
|
||||||
#: Whether registering requires an invite code issued by an administrator.
|
|
||||||
"invite_only": False,
|
|
||||||
#: Whether the only way in is the identity provider. Read here as well as
|
#: Whether the only way in is the identity provider. Read here as well as
|
||||||
#: in the SSO routes because it governs every email-based way of signing
|
#: in the SSO routes because it governs every email-based way of signing
|
||||||
#: in, link included, and each of those had been reading the key itself.
|
#: in, link included, and each of those had been reading the key itself.
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,6 @@
|
||||||
"422"
|
"422"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"DELETE /api/v1/admin/invites/{invite_id}": {
|
|
||||||
"body": false,
|
|
||||||
"params": [
|
|
||||||
"path:invite_id"
|
|
||||||
],
|
|
||||||
"responses": [
|
|
||||||
"204",
|
|
||||||
"422"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"DELETE /api/v1/admin/models/{model_id}": {
|
"DELETE /api/v1/admin/models/{model_id}": {
|
||||||
"body": false,
|
"body": false,
|
||||||
"params": [
|
"params": [
|
||||||
|
|
@ -550,13 +540,6 @@
|
||||||
"200"
|
"200"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"GET /api/v1/admin/invites": {
|
|
||||||
"body": false,
|
|
||||||
"params": [],
|
|
||||||
"responses": [
|
|
||||||
"200"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"GET /api/v1/admin/models": {
|
"GET /api/v1/admin/models": {
|
||||||
"body": false,
|
"body": false,
|
||||||
"params": [],
|
"params": [],
|
||||||
|
|
@ -2003,14 +1986,6 @@
|
||||||
"200"
|
"200"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"POST /api/v1/admin/invites": {
|
|
||||||
"body": true,
|
|
||||||
"params": [],
|
|
||||||
"responses": [
|
|
||||||
"201",
|
|
||||||
"422"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"POST /api/v1/admin/litellm/models": {
|
"POST /api/v1/admin/litellm/models": {
|
||||||
"body": true,
|
"body": true,
|
||||||
"params": [],
|
"params": [],
|
||||||
|
|
@ -2228,22 +2203,6 @@
|
||||||
"422"
|
"422"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"POST /api/v1/auth/login-code": {
|
|
||||||
"body": true,
|
|
||||||
"params": [],
|
|
||||||
"responses": [
|
|
||||||
"200",
|
|
||||||
"422"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"POST /api/v1/auth/login-code/verify": {
|
|
||||||
"body": true,
|
|
||||||
"params": [],
|
|
||||||
"responses": [
|
|
||||||
"200",
|
|
||||||
"422"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"POST /api/v1/auth/logout": {
|
"POST /api/v1/auth/logout": {
|
||||||
"body": true,
|
"body": true,
|
||||||
"params": [],
|
"params": [],
|
||||||
|
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
"""Invite-only sign-up, and the switches an administrator sets once.
|
|
||||||
|
|
||||||
Disposable SQLite; Redis is a Mock. The rules worth pinning: a code works once,
|
|
||||||
a spent code is kept rather than deleted, the flag falls back to its default
|
|
||||||
when Redis is down — and, for the gate that protects sign-up, the default is
|
|
||||||
the safe direction.
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from types import ModuleType
|
|
||||||
from unittest.mock import Mock, patch
|
|
||||||
|
|
||||||
import test_quiz_builder as fixtures
|
|
||||||
from app.models.invite import InviteCode
|
|
||||||
from app.models.user import User
|
|
||||||
from app.services import invites, site_settings
|
|
||||||
|
|
||||||
|
|
||||||
def fake_redis(store, broken=False):
|
|
||||||
client = Mock()
|
|
||||||
if broken:
|
|
||||||
client.get.side_effect = ConnectionError("redis down")
|
|
||||||
client.set.side_effect = ConnectionError("redis down")
|
|
||||||
else:
|
|
||||||
client.get.side_effect = lambda k: store.get(k)
|
|
||||||
client.set.side_effect = lambda k, v: store.__setitem__(k, v)
|
|
||||||
module = Mock()
|
|
||||||
module.from_url.return_value = client
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
class FlagTests(unittest.TestCase):
|
|
||||||
def test_unset_flags_take_their_default(self):
|
|
||||||
with patch.dict(sys.modules, {"redis": fake_redis({})}):
|
|
||||||
self.assertTrue(site_settings.get_flag("sharing_enabled"))
|
|
||||||
self.assertFalse(site_settings.get_flag("invite_only"))
|
|
||||||
|
|
||||||
def test_a_flag_reads_back_what_was_set(self):
|
|
||||||
store = {}
|
|
||||||
with patch.dict(sys.modules, {"redis": fake_redis(store)}):
|
|
||||||
site_settings.set_flag("invite_only", True)
|
|
||||||
self.assertEqual(store["settings:invite_only"], "true")
|
|
||||||
self.assertTrue(site_settings.get_flag("invite_only"))
|
|
||||||
|
|
||||||
def test_losing_redis_falls_back_rather_than_failing(self):
|
|
||||||
with patch.dict(sys.modules, {"redis": fake_redis({}, broken=True)}):
|
|
||||||
# Sharing keeps working; sign-up does not silently open.
|
|
||||||
self.assertTrue(site_settings.get_flag("sharing_enabled"))
|
|
||||||
self.assertFalse(site_settings.get_flag("invite_only"))
|
|
||||||
|
|
||||||
def test_an_unknown_flag_is_refused_rather_than_invented(self):
|
|
||||||
with self.assertRaises(KeyError):
|
|
||||||
site_settings.get_flag("nonsense")
|
|
||||||
with self.assertRaises(KeyError):
|
|
||||||
site_settings.set_flag("nonsense", True)
|
|
||||||
|
|
||||||
|
|
||||||
class InviteTests(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.bank = fixtures.BuilderTests()
|
|
||||||
self.bank.setUp()
|
|
||||||
self.db = self.bank.db
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self.bank.tearDown()
|
|
||||||
|
|
||||||
def test_a_code_is_unambiguous_to_read_aloud(self):
|
|
||||||
code = invites.generate_code()
|
|
||||||
self.assertEqual(len(code), invites.LENGTH)
|
|
||||||
# No O/0 or I/1/l to mistype.
|
|
||||||
self.assertFalse(set(code) & set("O0I1l"))
|
|
||||||
|
|
||||||
def test_a_code_works_once(self):
|
|
||||||
row = invites.create(self.db, created_by=None, note="For a new tutor")
|
|
||||||
self.assertIsNotNone(invites.usable(self.db, row.code))
|
|
||||||
# Case and surrounding space are forgiven; a typed code is typed.
|
|
||||||
self.assertIsNotNone(invites.usable(self.db, f" {row.code.lower()} "))
|
|
||||||
|
|
||||||
invites.spend(self.db, row, self.bank.peer)
|
|
||||||
self.assertIsNone(invites.usable(self.db, row.code))
|
|
||||||
# Kept, not deleted: who it let in is the record worth having.
|
|
||||||
self.assertEqual(self.db.query(InviteCode).count(), 1)
|
|
||||||
self.assertEqual(self.db.get(InviteCode, row.id).used_by, self.bank.peer.id)
|
|
||||||
|
|
||||||
def test_nothing_and_nonsense_are_not_codes(self):
|
|
||||||
self.assertIsNone(invites.usable(self.db, None))
|
|
||||||
self.assertIsNone(invites.usable(self.db, ""))
|
|
||||||
self.assertIsNone(invites.usable(self.db, "NOTACODE12"))
|
|
||||||
|
|
||||||
def test_a_withdrawn_code_stops_working(self):
|
|
||||||
from datetime import datetime
|
|
||||||
row = invites.create(self.db, created_by=None)
|
|
||||||
row.revoked_at = datetime.utcnow()
|
|
||||||
self.db.commit()
|
|
||||||
self.assertIsNone(invites.usable(self.db, row.code))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|
||||||
|
|
||||||
class TutorFlagTests(unittest.TestCase):
|
|
||||||
"""Whether the tutor may be opened during a session.
|
|
||||||
|
|
||||||
The rule that is not a setting: the tutor is handed the correct answer and
|
|
||||||
told it may explain it, so an exam-mode tutor is an answer key. That is
|
|
||||||
refused whatever the flag says. The flag only decides study mode.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_the_tutor_is_allowed_by_default(self):
|
|
||||||
with patch.dict(sys.modules, {"redis": fake_redis({})}):
|
|
||||||
self.assertTrue(site_settings.get_flag("tutor_in_quiz"))
|
|
||||||
|
|
||||||
def test_turning_it_off_reads_back(self):
|
|
||||||
store = {}
|
|
||||||
with patch.dict(sys.modules, {"redis": fake_redis(store)}):
|
|
||||||
site_settings.set_flag("tutor_in_quiz", False)
|
|
||||||
self.assertFalse(site_settings.get_flag("tutor_in_quiz"))
|
|
||||||
|
|
||||||
def test_redis_being_down_leaves_the_tutor_on(self):
|
|
||||||
# The safe direction here is the permissive one: losing Redis should
|
|
||||||
# not silently remove a study aid. Nothing is revealed that study mode
|
|
||||||
# does not already show.
|
|
||||||
with patch.dict(sys.modules, {"redis": fake_redis({}, broken=True)}):
|
|
||||||
self.assertTrue(site_settings.get_flag("tutor_in_quiz"))
|
|
||||||
|
|
@ -1,290 +0,0 @@
|
||||||
"""Signing in with a code sent by email.
|
|
||||||
|
|
||||||
The properties worth pinning are the ones that are invisible when they break: a
|
|
||||||
stranger's address gets the same answer as a member's, in the same time; a code
|
|
||||||
works once and not after that; a wrong guess costs one of five and the fifth
|
|
||||||
ends the code; and the limiter counts requests for an address that has no
|
|
||||||
account exactly as it counts the others, or the 429 becomes the answer the
|
|
||||||
identical message was there to withhold.
|
|
||||||
|
|
||||||
Disposable SQLite, a Mock for Redis, and nothing leaves the process.
|
|
||||||
"""
|
|
||||||
import hashlib
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import unittest
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from types import ModuleType
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
import test_quiz_builder as fixtures
|
|
||||||
from app.models.email_verification import EmailVerification
|
|
||||||
from app.models.login_code import LoginCode
|
|
||||||
from app.routers import login_code
|
|
||||||
from app.services import email_service, invites, login_codes
|
|
||||||
|
|
||||||
|
|
||||||
class MemoryRedis:
|
|
||||||
def __init__(self): self.values = {}
|
|
||||||
def get(self, key): return self.values.get(key)
|
|
||||||
def set(self, key, value): self.values[key] = value
|
|
||||||
def incr(self, key):
|
|
||||||
self.values[key] = int(self.values.get(key, 0)) + 1
|
|
||||||
return self.values[key]
|
|
||||||
def expire(self, *args): return True
|
|
||||||
|
|
||||||
|
|
||||||
class LoginCodeTests(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.bank = fixtures.BuilderTests()
|
|
||||||
self.bank.setUp()
|
|
||||||
self.bank.owner.email = "owner@example.com"
|
|
||||||
# The peer too: the address goes through EmailStr on the way in, which
|
|
||||||
# refuses the reserved .test domain the fixtures otherwise use.
|
|
||||||
self.bank.peer.email = "peer@example.com"
|
|
||||||
self.bank.db.commit()
|
|
||||||
self.client = self.bank.client
|
|
||||||
self.client.app.include_router(login_code.router, prefix="/auth")
|
|
||||||
|
|
||||||
self.redis = MemoryRedis()
|
|
||||||
module = ModuleType("redis")
|
|
||||||
module.from_url = lambda *args, **kwargs: self.redis
|
|
||||||
modules = patch.dict(sys.modules, {"redis": module})
|
|
||||||
modules.start()
|
|
||||||
self.addCleanup(modules.stop)
|
|
||||||
|
|
||||||
# Every reply is paced to a floor; the pacing itself has its own test,
|
|
||||||
# and the rest of the suite has no reason to wait for it.
|
|
||||||
floor = patch.object(login_code, "MIN_SECONDS", 0.01)
|
|
||||||
floor.start()
|
|
||||||
self.addCleanup(floor.stop)
|
|
||||||
|
|
||||||
sent = patch.object(email_service, "send_login_code_email", new_callable=AsyncMock)
|
|
||||||
self.sent = sent.start()
|
|
||||||
self.addCleanup(sent.stop)
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self.bank.tearDown()
|
|
||||||
|
|
||||||
def ask(self, email="owner@example.com"):
|
|
||||||
return self.client.post("/auth/login-code", json={"email": email})
|
|
||||||
|
|
||||||
def code_for(self, email="owner@example.com"):
|
|
||||||
"""The code as the mail shows it — spaces and all."""
|
|
||||||
self.sent.reset_mock()
|
|
||||||
self.assertEqual(self.ask(email).status_code, 200)
|
|
||||||
self.sent.assert_awaited_once()
|
|
||||||
return self.sent.await_args.args[2]
|
|
||||||
|
|
||||||
def enter(self, code, email="owner@example.com"):
|
|
||||||
return self.client.post("/auth/login-code/verify", json={"email": email, "code": code})
|
|
||||||
|
|
||||||
def wrong(self, email="owner@example.com"):
|
|
||||||
return self.enter("ZZZZZZ", email)
|
|
||||||
|
|
||||||
# ── the answer gives nothing away ──────────────────────────────────
|
|
||||||
|
|
||||||
def test_a_stranger_gets_the_member_s_answer_word_for_word(self):
|
|
||||||
known = self.ask()
|
|
||||||
unknown = self.ask("nobody@example.com")
|
|
||||||
self.assertEqual(known.status_code, unknown.status_code)
|
|
||||||
self.assertEqual(known.json(), unknown.json())
|
|
||||||
self.assertEqual(known.json(), {"message": login_code.SENT})
|
|
||||||
# And the only difference behind it is one that never reaches the caller.
|
|
||||||
self.assertEqual(self.bank.db.query(LoginCode).count(), 1)
|
|
||||||
|
|
||||||
def test_both_answers_are_held_to_the_same_floor(self):
|
|
||||||
# The real constant, because the point of the test is the value of it.
|
|
||||||
with patch.object(login_code, "MIN_SECONDS", 0.3):
|
|
||||||
times = []
|
|
||||||
for address in ("owner@example.com", "nobody@example.com"):
|
|
||||||
started = time.perf_counter()
|
|
||||||
self.ask(address)
|
|
||||||
times.append(time.perf_counter() - started)
|
|
||||||
for elapsed in times:
|
|
||||||
self.assertGreaterEqual(elapsed, 0.3)
|
|
||||||
self.assertLess(abs(times[0] - times[1]), 0.15)
|
|
||||||
|
|
||||||
def test_a_wrong_code_reads_the_same_as_an_address_with_no_account(self):
|
|
||||||
self.code_for()
|
|
||||||
mistyped = self.wrong()
|
|
||||||
stranger = self.enter("ZZZZZZ", "nobody@example.com")
|
|
||||||
self.assertEqual(mistyped.status_code, stranger.status_code)
|
|
||||||
self.assertEqual(mistyped.json(), stranger.json())
|
|
||||||
self.assertEqual(mistyped.json()["detail"], login_code.REFUSED)
|
|
||||||
|
|
||||||
def test_the_limiter_counts_a_stranger_s_address_too(self):
|
|
||||||
# Otherwise the 429 arrives only for addresses that exist, and the
|
|
||||||
# identical message above has been undone by the thing protecting it.
|
|
||||||
for _ in range(login_code.PER_ADDRESS_CALLS):
|
|
||||||
self.assertEqual(self.ask("nobody@example.com").status_code, 200)
|
|
||||||
self.assertEqual(self.ask("nobody@example.com").status_code, 429)
|
|
||||||
self.assertEqual(self.ask().status_code, 200)
|
|
||||||
|
|
||||||
def test_the_address_limit_bites_on_the_fourth_request(self):
|
|
||||||
for _ in range(login_code.PER_ADDRESS_CALLS):
|
|
||||||
self.assertEqual(self.ask().status_code, 200)
|
|
||||||
refused = self.ask()
|
|
||||||
self.assertEqual(refused.status_code, 429)
|
|
||||||
self.assertIn("Too many sign-in codes", refused.json()["detail"])
|
|
||||||
# Nothing was issued on the refused attempt.
|
|
||||||
self.assertEqual(self.bank.db.query(LoginCode).count(), login_code.PER_ADDRESS_CALLS)
|
|
||||||
|
|
||||||
def test_the_network_limit_is_the_looser_of_the_two(self):
|
|
||||||
self.assertGreater(login_code.PER_IP_CALLS, login_code.PER_ADDRESS_CALLS)
|
|
||||||
# A different address each time, so the tighter limit cannot be what
|
|
||||||
# stops it.
|
|
||||||
for n in range(login_code.PER_IP_CALLS):
|
|
||||||
self.assertEqual(self.ask(f"person{n}@example.com").status_code, 200)
|
|
||||||
refused = self.ask(f"person{login_code.PER_IP_CALLS}@example.com")
|
|
||||||
self.assertEqual(refused.status_code, 429)
|
|
||||||
self.assertIn("from this network", refused.json()["detail"])
|
|
||||||
|
|
||||||
# ── the code itself ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_a_code_signs_you_in_once_and_never_again(self):
|
|
||||||
code = self.code_for()
|
|
||||||
first = self.enter(code)
|
|
||||||
self.assertEqual(first.status_code, 200, first.text)
|
|
||||||
self.assertTrue(first.json()["access_token"])
|
|
||||||
second = self.enter(code)
|
|
||||||
self.assertEqual(second.status_code, 400)
|
|
||||||
self.assertEqual(second.json()["detail"], login_code.REFUSED)
|
|
||||||
|
|
||||||
def test_six_characters_of_the_house_alphabet(self):
|
|
||||||
code = login_codes.normalise(self.code_for())
|
|
||||||
self.assertEqual(len(code), login_codes.LENGTH)
|
|
||||||
self.assertEqual(login_codes.LENGTH, 6)
|
|
||||||
self.assertTrue(set(code) <= set(invites.ALPHABET))
|
|
||||||
# The characters that get misread are not in it to be misread.
|
|
||||||
self.assertFalse(set("O0I1") & set(invites.ALPHABET))
|
|
||||||
|
|
||||||
def test_case_spaces_and_dashes_do_not_decide_who_gets_in(self):
|
|
||||||
code = login_codes.normalise(self.code_for())
|
|
||||||
typed = f" {code[:3].lower()}-{code[3:]} "
|
|
||||||
self.assertEqual(self.enter(typed).status_code, 200)
|
|
||||||
|
|
||||||
def test_the_code_as_the_mail_shows_it_types_straight_back(self):
|
|
||||||
shown = self.code_for()
|
|
||||||
self.assertIn(" ", shown)
|
|
||||||
self.assertEqual(self.enter(shown).status_code, 200)
|
|
||||||
|
|
||||||
def test_an_expired_code_is_refused(self):
|
|
||||||
code = self.code_for()
|
|
||||||
row = self.bank.db.query(LoginCode).one()
|
|
||||||
row.expires_at = datetime.utcnow() - timedelta(seconds=1)
|
|
||||||
self.bank.db.commit()
|
|
||||||
self.assertEqual(self.enter(code).status_code, 400)
|
|
||||||
# Refused without being marked spent: it was never used, and the row
|
|
||||||
# says so.
|
|
||||||
self.bank.db.refresh(row)
|
|
||||||
self.assertIsNone(row.consumed_at)
|
|
||||||
|
|
||||||
def test_fifteen_minutes_and_not_an_hour(self):
|
|
||||||
self.code_for()
|
|
||||||
row = self.bank.db.query(LoginCode).one()
|
|
||||||
self.assertAlmostEqual((row.expires_at - row.created_at).total_seconds(), 15 * 60, delta=5)
|
|
||||||
|
|
||||||
def test_a_second_request_retires_the_first_code(self):
|
|
||||||
stale = self.code_for()
|
|
||||||
fresh = self.code_for()
|
|
||||||
self.assertEqual(self.enter(stale).status_code, 400)
|
|
||||||
self.assertEqual(self.enter(fresh).status_code, 200)
|
|
||||||
|
|
||||||
def test_the_table_holds_a_fingerprint_rather_than_the_code(self):
|
|
||||||
code = login_codes.normalise(self.code_for())
|
|
||||||
row = self.bank.db.query(LoginCode).one()
|
|
||||||
self.assertNotIn(code, str(row.code_hash))
|
|
||||||
self.assertEqual(row.code_hash, hashlib.sha256(code.encode()).hexdigest())
|
|
||||||
|
|
||||||
def test_a_code_is_no_good_against_somebody_else_s_address(self):
|
|
||||||
# A short code looked up on its own would be tried against every code
|
|
||||||
# live on the site at once. It is only ever tried against one account.
|
|
||||||
code = self.code_for()
|
|
||||||
self.assertEqual(self.enter(code, "peer@example.com").status_code, 400)
|
|
||||||
self.assertEqual(self.enter(code).status_code, 200)
|
|
||||||
|
|
||||||
# ── guessing costs something ───────────────────────────────────────
|
|
||||||
|
|
||||||
def test_a_wrong_code_counts_against_the_limit(self):
|
|
||||||
self.code_for()
|
|
||||||
for expected in range(1, 3):
|
|
||||||
self.assertEqual(self.wrong().status_code, 400)
|
|
||||||
self.assertEqual(self.bank.db.query(LoginCode).one().attempts, expected)
|
|
||||||
|
|
||||||
def test_a_code_out_of_guesses_is_retired_rather_than_left_guessable(self):
|
|
||||||
code = self.code_for()
|
|
||||||
for _ in range(login_codes.MAX_ATTEMPTS):
|
|
||||||
self.assertEqual(self.wrong().status_code, 400)
|
|
||||||
row = self.bank.db.query(LoginCode).one()
|
|
||||||
self.assertIsNotNone(row.consumed_at)
|
|
||||||
# And now not even the right code opens it — the way back is a new one.
|
|
||||||
self.assertEqual(self.enter(code).status_code, 400)
|
|
||||||
self.assertEqual(self.enter(self.code_for()).status_code, 200)
|
|
||||||
|
|
||||||
def test_the_attempts_limit_keeps_counting_after_the_code_is_dead(self):
|
|
||||||
# The code's own five run out first. The address limit is what stops
|
|
||||||
# somebody working through codes to buy five more guesses at a time.
|
|
||||||
self.code_for()
|
|
||||||
for _ in range(login_code.PER_ADDRESS_TRIES):
|
|
||||||
self.assertEqual(self.wrong().status_code, 400)
|
|
||||||
refused = self.wrong()
|
|
||||||
self.assertEqual(refused.status_code, 429)
|
|
||||||
self.assertIn("Too many attempts", refused.json()["detail"])
|
|
||||||
|
|
||||||
def test_guessing_a_stranger_s_address_is_counted_the_same_way(self):
|
|
||||||
for _ in range(login_code.PER_ADDRESS_TRIES):
|
|
||||||
self.assertEqual(self.wrong("nobody@example.com").status_code, 400)
|
|
||||||
self.assertEqual(self.wrong("nobody@example.com").status_code, 429)
|
|
||||||
|
|
||||||
# ── the rules the password form already keeps ──────────────────────
|
|
||||||
|
|
||||||
def test_an_unverified_account_is_turned_away_as_it_is_at_the_password_form(self):
|
|
||||||
self.bank.db.add(EmailVerification(user_id=self.bank.owner.id, token="verification-token",
|
|
||||||
expires_at=datetime.utcnow() + timedelta(hours=1), verified_at=None))
|
|
||||||
self.bank.db.commit()
|
|
||||||
code = self.code_for()
|
|
||||||
refused = self.enter(code)
|
|
||||||
self.assertEqual(refused.status_code, 403)
|
|
||||||
self.assertIn("Email not verified", refused.json()["detail"])
|
|
||||||
# Refused, and the code spent all the same: turning somebody away must
|
|
||||||
# not leave a working credential behind them.
|
|
||||||
self.assertIsNotNone(self.bank.db.query(LoginCode).one().consumed_at)
|
|
||||||
|
|
||||||
def test_sso_only_closes_this_door_as_well_as_the_password_one(self):
|
|
||||||
code = self.code_for()
|
|
||||||
self.redis.values["settings:sso_only"] = "true"
|
|
||||||
refused = self.ask()
|
|
||||||
self.assertEqual(refused.status_code, 403)
|
|
||||||
self.assertIn("Please use SSO", refused.json()["detail"])
|
|
||||||
self.assertEqual(self.enter(code).status_code, 403)
|
|
||||||
self.assertEqual(self.bank.db.query(LoginCode).count(), 1)
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceTests(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.bank = fixtures.BuilderTests()
|
|
||||||
self.bank.setUp()
|
|
||||||
self.addCleanup(self.bank.tearDown)
|
|
||||||
|
|
||||||
def test_only_one_of_two_claims_on_one_code_succeeds(self):
|
|
||||||
code = login_codes.issue(self.bank.db, self.bank.owner)
|
|
||||||
self.assertIsNotNone(login_codes.verify(self.bank.db, self.bank.owner.email, code))
|
|
||||||
self.assertIsNone(login_codes.verify(self.bank.db, self.bank.owner.email, code))
|
|
||||||
|
|
||||||
def test_rows_long_past_their_expiry_are_swept_on_the_next_request(self):
|
|
||||||
login_codes.issue(self.bank.db, self.bank.owner)
|
|
||||||
stale = self.bank.db.query(LoginCode).one()
|
|
||||||
stale.expires_at = datetime.utcnow() - timedelta(days=3)
|
|
||||||
self.bank.db.commit()
|
|
||||||
login_codes.issue(self.bank.db, self.bank.owner)
|
|
||||||
self.assertEqual(self.bank.db.query(LoginCode).count(), 1)
|
|
||||||
|
|
||||||
def test_normalising_keeps_only_what_the_code_is_made_of(self):
|
|
||||||
self.assertEqual(login_codes.normalise(" ab3-c d9 "), "AB3CD9")
|
|
||||||
self.assertEqual(login_codes.normalise("abc\u2013def"), "ABCDEF")
|
|
||||||
self.assertEqual(login_codes.normalise(""), "")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|
@ -223,6 +223,10 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) {
|
||||||
{ to: '/study-plans', label: 'Study plans' },
|
{ to: '/study-plans', label: 'Study plans' },
|
||||||
{ to: '/articles', label: 'Reading' },
|
{ to: '/articles', label: 'Reading' },
|
||||||
{ to: '/flashcards', label: 'Cards' },
|
{ to: '/flashcards', label: 'Cards' },
|
||||||
|
// The scribe, where a deck is written from your own material. A different
|
||||||
|
// application behind the same sign-in, so it is a real link out rather
|
||||||
|
// than a route — and it says so with the arrow.
|
||||||
|
{ to: 'https://app.pedshub.com/#resources', label: 'Make a deck', external: true },
|
||||||
] : []
|
] : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -310,10 +314,16 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) {
|
||||||
a mouse the last links were unreachable. */}
|
a mouse the last links were unreachable. */}
|
||||||
<ScrollStrip as="nav" className="nav-sections" label="Sections">
|
<ScrollStrip as="nav" className="nav-sections" label="Sections">
|
||||||
{navLinks.map(l => (
|
{navLinks.map(l => (
|
||||||
|
l.external ? (
|
||||||
|
<a key={l.to} href={l.to} target="_blank" rel="noopener noreferrer">
|
||||||
|
{l.label} ↗
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
<Link key={l.to} to={l.to} className={location.pathname === l.to ? 'is-current' : undefined}
|
<Link key={l.to} to={l.to} className={location.pathname === l.to ? 'is-current' : undefined}
|
||||||
aria-current={location.pathname === l.to ? 'page' : undefined}>
|
aria-current={location.pathname === l.to ? 'page' : undefined}>
|
||||||
{l.label}
|
{l.label}
|
||||||
</Link>
|
</Link>
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
</ScrollStrip>
|
</ScrollStrip>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -327,17 +337,24 @@ export default function Navbar({ onSignIn, onRegister, onSearch }) {
|
||||||
padding: '8px 0 12px',
|
padding: '8px 0 12px',
|
||||||
}}>
|
}}>
|
||||||
<div className="container" style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<div className="container" style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
{navLinks.map(l => (
|
{navLinks.map(l => {
|
||||||
<Link key={l.to} to={l.to} style={{
|
const style = {
|
||||||
color: 'var(--navbar-fg)', textDecoration: 'none',
|
color: 'var(--navbar-fg)', textDecoration: 'none',
|
||||||
padding: '10px 4px', fontSize: '0.95rem',
|
padding: '10px 4px', fontSize: '0.95rem',
|
||||||
fontWeight: location.pathname === l.to ? 600 : 400,
|
fontWeight: location.pathname === l.to ? 600 : 400,
|
||||||
opacity: location.pathname === l.to ? 1 : 0.75,
|
opacity: location.pathname === l.to ? 1 : 0.75,
|
||||||
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||||
}}>
|
}
|
||||||
|
return l.external ? (
|
||||||
|
<a key={l.to} href={l.to} target="_blank" rel="noopener noreferrer" style={style}>
|
||||||
|
{l.label} ↗
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Link key={l.to} to={l.to} style={style}>
|
||||||
{l.label}
|
{l.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
<button onClick={() => { setMenuOpen(false); logout() }} style={{
|
<button onClick={() => { setMenuOpen(false); logout() }} style={{
|
||||||
background: 'rgba(255,255,255,0.08)', border: '1px solid rgba(255,255,255,0.15)',
|
background: 'rgba(255,255,255,0.08)', border: '1px solid rgba(255,255,255,0.15)',
|
||||||
color: 'var(--navbar-fg)', padding: '10px', borderRadius: 8,
|
color: 'var(--navbar-fg)', padding: '10px', borderRadius: 8,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ const when = (value) => (value ? new Date(value).toLocaleDateString(undefined,
|
||||||
*/
|
*/
|
||||||
export default function SitePolicy() {
|
export default function SitePolicy() {
|
||||||
const [flags, setFlags] = useState({
|
const [flags, setFlags] = useState({
|
||||||
registration_enabled: true, sharing_enabled: true, invite_only: false,
|
registration_enabled: true, sharing_enabled: true,
|
||||||
tutor_in_quiz: true, sso_only: false,
|
tutor_in_quiz: true, sso_only: false,
|
||||||
clinical_library_enabled: false, pubmed_enabled: false,
|
clinical_library_enabled: false, pubmed_enabled: false,
|
||||||
})
|
})
|
||||||
|
|
@ -27,20 +27,16 @@ export default function SitePolicy() {
|
||||||
clinical_mcp_url: '', pubmed_api_key: '', pubmed_contact_email: '',
|
clinical_mcp_url: '', pubmed_api_key: '', pubmed_contact_email: '',
|
||||||
})
|
})
|
||||||
const [sso, setSso] = useState({ configured: false, name: '' })
|
const [sso, setSso] = useState({ configured: false, name: '' })
|
||||||
const [codes, setCodes] = useState([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [note, setNote] = useState('')
|
|
||||||
const [copied, setCopied] = useState(null)
|
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
Promise.all([api.get('/admin/settings'), api.get('/admin/invites')])
|
Promise.all([api.get('/admin/settings')])
|
||||||
.then(([settings, invites]) => {
|
.then(([settings]) => {
|
||||||
setFlags({
|
setFlags({
|
||||||
registration_enabled: settings.data.registration_enabled !== false,
|
registration_enabled: settings.data.registration_enabled !== false,
|
||||||
sharing_enabled: settings.data.sharing_enabled !== false,
|
sharing_enabled: settings.data.sharing_enabled !== false,
|
||||||
invite_only: settings.data.invite_only === true,
|
|
||||||
tutor_in_quiz: settings.data.tutor_in_quiz !== false,
|
tutor_in_quiz: settings.data.tutor_in_quiz !== false,
|
||||||
sso_only: settings.data.sso_only === true,
|
sso_only: settings.data.sso_only === true,
|
||||||
clinical_library_enabled: settings.data.clinical_library_enabled === true,
|
clinical_library_enabled: settings.data.clinical_library_enabled === true,
|
||||||
|
|
@ -55,7 +51,6 @@ export default function SitePolicy() {
|
||||||
configured: !!settings.data.sso_configured,
|
configured: !!settings.data.sso_configured,
|
||||||
name: settings.data.sso_provider_name || 'your identity provider',
|
name: settings.data.sso_provider_name || 'your identity provider',
|
||||||
})
|
})
|
||||||
setCodes(invites.data || [])
|
|
||||||
})
|
})
|
||||||
.catch(() => setError('Could not load the site policy'))
|
.catch(() => setError('Could not load the site policy'))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
|
|
@ -82,23 +77,8 @@ export default function SitePolicy() {
|
||||||
return run(() => api.put('/admin/settings', { [name]: value }), 'Could not save that')
|
return run(() => api.put('/admin/settings', { [name]: value }), 'Could not save that')
|
||||||
}
|
}
|
||||||
|
|
||||||
const issue = () => run(
|
|
||||||
() => api.post('/admin/invites', { note: note.trim() || null }).then(res => { setNote(''); return res }),
|
|
||||||
'Could not create a code')
|
|
||||||
|
|
||||||
const revoke = (row) => run(() => api.delete(`/admin/invites/${row.id}`),
|
|
||||||
row.status === 'open' ? 'Could not withdraw that code' : 'Could not remove that code')
|
|
||||||
|
|
||||||
const copy = (code) => {
|
|
||||||
navigator.clipboard?.writeText(code)
|
|
||||||
setCopied(code)
|
|
||||||
setTimeout(() => setCopied(null), 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) return <div className="loading"><div className="spinner" /></div>
|
if (loading) return <div className="loading"><div className="spinner" /></div>
|
||||||
|
|
||||||
const open = codes.filter(row => row.status === 'open')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sp">
|
<div className="sp">
|
||||||
{error && <p className="sp-error" role="alert">{error}</p>}
|
{error && <p className="sp-error" role="alert">{error}</p>}
|
||||||
|
|
@ -113,7 +93,7 @@ export default function SitePolicy() {
|
||||||
<span>
|
<span>
|
||||||
<strong>Anyone may register</strong>
|
<strong>Anyone may register</strong>
|
||||||
<small>
|
<small>
|
||||||
Turn this off and nobody new can join at all, invite code or not.
|
Turn this off and nobody new can join at all.
|
||||||
People who already have an account keep it.
|
People who already have an account keep it.
|
||||||
</small>
|
</small>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -126,17 +106,6 @@ export default function SitePolicy() {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<label className="sp-switch">
|
|
||||||
<input type="checkbox" checked={flags.invite_only} disabled={busy || !flags.registration_enabled}
|
|
||||||
onChange={e => toggle('invite_only', e.target.checked)} />
|
|
||||||
<span>
|
|
||||||
<strong>Invite only</strong>
|
|
||||||
<small>
|
|
||||||
Registering requires a code issued here. Anyone who already has an
|
|
||||||
account keeps it.
|
|
||||||
</small>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="sp-switch">
|
<label className="sp-switch">
|
||||||
<input type="checkbox" checked={flags.sharing_enabled} disabled={busy}
|
<input type="checkbox" checked={flags.sharing_enabled} disabled={busy}
|
||||||
|
|
@ -234,55 +203,6 @@ export default function SitePolicy() {
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{flags.invite_only && flags.registration_enabled && (
|
|
||||||
<section className="sp-codes">
|
|
||||||
<h3>Invite codes <small>{open.length} unused</small></h3>
|
|
||||||
|
|
||||||
<div className="sp-issue">
|
|
||||||
<input value={note} maxLength={200} placeholder="Who is it for? (optional)"
|
|
||||||
aria-label="Who the invite is for" onChange={e => setNote(e.target.value)} />
|
|
||||||
<button type="button" className="btn btn-primary btn-sm" disabled={busy} onClick={issue}>
|
|
||||||
Create a code
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{codes.length === 0 ? (
|
|
||||||
<p className="sp-empty">No codes yet. Create one to let somebody in.</p>
|
|
||||||
) : (
|
|
||||||
<ul className="sp-list">
|
|
||||||
{codes.map(row => (
|
|
||||||
<li key={row.id} className={`sp-code is-${row.status}`}>
|
|
||||||
<code>{row.code}</code>
|
|
||||||
<span className="sp-code-note">
|
|
||||||
{row.note || <em>no note</em>}
|
|
||||||
{row.status === 'used' && (
|
|
||||||
<small>Used by {row.used_by_name || 'someone'} on {when(row.used_at)}</small>
|
|
||||||
)}
|
|
||||||
{row.status === 'revoked' && <small>Withdrawn {when(row.revoked_at)}</small>}
|
|
||||||
</span>
|
|
||||||
<span className="sp-code-actions">
|
|
||||||
{row.status === 'open' ? (
|
|
||||||
<>
|
|
||||||
<button type="button" className="btn btn-secondary btn-sm"
|
|
||||||
onClick={() => copy(row.code)}>{copied === row.code ? '✓ Copied' : 'Copy'}</button>
|
|
||||||
<button type="button" className="btn btn-secondary btn-sm sp-revoke"
|
|
||||||
disabled={busy} aria-label={`Withdraw ${row.code}`}
|
|
||||||
onClick={() => revoke(row)}>Withdraw</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
// A spent code has nothing left to stop, and a list that
|
|
||||||
// only grows is a list nobody reads.
|
|
||||||
<button type="button" className="btn btn-secondary btn-sm sp-revoke"
|
|
||||||
disabled={busy} aria-label={`Remove ${row.code}`}
|
|
||||||
onClick={() => revoke(row)}>Remove</button>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -247,12 +247,6 @@ function AuthModal({ mode, onClose, onSwitch }) {
|
||||||
const [registered, setRegistered] = useState(false)
|
const [registered, setRegistered] = useState(false)
|
||||||
// Typed twice. The same field exists on /register; this is the other door.
|
// Typed twice. The same field exists on /register; this is the other door.
|
||||||
const [confirm, setConfirm] = useState('')
|
const [confirm, setConfirm] = useState('')
|
||||||
// Whether this site is invite only. The register page asks the same
|
|
||||||
// question; this form is the other door and used not to ask at all, so
|
|
||||||
// turning the gate on left everyone here failing with "an invite code is
|
|
||||||
// required" and nowhere to put one.
|
|
||||||
const [inviteRequired, setInviteRequired] = useState(false)
|
|
||||||
const [inviteCode, setInviteCode] = useState('')
|
|
||||||
const [signupOpen, setSignupOpen] = useState(true)
|
const [signupOpen, setSignupOpen] = useState(true)
|
||||||
//: The public page's own sign-in box. It is a different component from
|
//: The public page's own sign-in box. It is a different component from
|
||||||
//: /login and knew nothing about the provider, so on a site where the only
|
//: /login and knew nothing about the provider, so on a site where the only
|
||||||
|
|
@ -267,7 +261,6 @@ function AuthModal({ mode, onClose, onSwitch }) {
|
||||||
api.get('/auth/signup-policy')
|
api.get('/auth/signup-policy')
|
||||||
.then(res => {
|
.then(res => {
|
||||||
if (!live) return
|
if (!live) return
|
||||||
setInviteRequired(!!res.data?.invite_required)
|
|
||||||
// Registration closed — by the switch, or because the site signs in
|
// Registration closed — by the switch, or because the site signs in
|
||||||
// through a provider. Either way there is no password sign-up to
|
// through a provider. Either way there is no password sign-up to
|
||||||
// offer, and a Register tab that leads to a refusal is worse than no
|
// offer, and a Register tab that leads to a refusal is worse than no
|
||||||
|
|
@ -315,7 +308,6 @@ function AuthModal({ mode, onClose, onSwitch }) {
|
||||||
const res = await api.post('/auth/register', {
|
const res = await api.post('/auth/register', {
|
||||||
email, password, name,
|
email, password, name,
|
||||||
captcha_token: captchaToken || null,
|
captcha_token: captchaToken || null,
|
||||||
invite_code: inviteCode.trim() || null,
|
|
||||||
})
|
})
|
||||||
if (res.data.requires_verification) {
|
if (res.data.requires_verification) {
|
||||||
setRegistered(true)
|
setRegistered(true)
|
||||||
|
|
@ -447,23 +439,10 @@ function AuthModal({ mode, onClose, onSwitch }) {
|
||||||
<small className="lp-mismatch">These do not match yet.</small>
|
<small className="lp-mismatch">These do not match yet.</small>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{inviteRequired && (
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="modal-invite-code">Invite code</label>
|
|
||||||
{/* Asked for only where it is needed. The form never says
|
|
||||||
whether a code is valid before the account is made —
|
|
||||||
that would be a place to guess them. */}
|
|
||||||
<input id="modal-invite-code" value={inviteCode} required autoComplete="off"
|
|
||||||
placeholder="From whoever invited you" className="lp-invite"
|
|
||||||
onChange={e => setInviteCode(e.target.value.toUpperCase())} />
|
|
||||||
<small className="lp-hint">This site is invite only.</small>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Captcha onVerify={setCaptchaToken} />
|
<Captcha onVerify={setCaptchaToken} />
|
||||||
<button className="btn btn-primary btn-block"
|
<button className="btn btn-primary btn-block"
|
||||||
disabled={loading || !password || password !== confirm
|
disabled={loading || !password || password !== confirm
|
||||||
|| (captchaSiteKey() && !captchaToken)
|
|| (captchaSiteKey() && !captchaToken)}>
|
||||||
|| (inviteRequired && !inviteCode.trim())}>
|
|
||||||
{loading ? 'Creating account…' : 'Sign Up'}
|
{loading ? 'Creating account…' : 'Sign Up'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -778,7 +757,9 @@ export default function LandingPage() {
|
||||||
<div className="lp-inner">
|
<div className="lp-inner">
|
||||||
<SlideStudio calm={calm} />
|
<SlideStudio calm={calm} />
|
||||||
<div className="lp-studio-cta">
|
<div className="lp-studio-cta">
|
||||||
<a href="https://app.pedshub.com" target="_blank" rel="noopener noreferrer"
|
{/* Straight to My Resources, which is where a deck is made — the
|
||||||
|
scribe reads the hash and opens there after sign-in. */}
|
||||||
|
<a href="https://app.pedshub.com/#resources" target="_blank" rel="noopener noreferrer"
|
||||||
className="btn lp-cta-ghost">Make a deck ↗</a>
|
className="btn lp-cta-ghost">Make a deck ↗</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,9 @@ afterEach(() => { document.getElementById('cap-widget-script')?.remove() })
|
||||||
function mount(Component) {
|
function mount(Component) {
|
||||||
render(<MemoryRouter initialEntries={['/entry']}><AuthProvider><Routes><Route path="/entry" element={<Component />} /><Route path="/" element={<div>Signed in successfully</div>} /></Routes></AuthProvider></MemoryRouter>)
|
render(<MemoryRouter initialEntries={['/entry']}><AuthProvider><Routes><Route path="/entry" element={<Component />} /><Route path="/" element={<div>Signed in successfully</div>} /></Routes></AuthProvider></MemoryRouter>)
|
||||||
}
|
}
|
||||||
async function submitLogin({ revealPassword = false } = {}) {
|
async function submitLogin() {
|
||||||
// The standalone page leads with a code by email; the password is behind a
|
// Both pages ask for the password outright now. Sign-in codes were the
|
||||||
// second option, and the landing modal still asks for it outright.
|
// standalone page's first offer; they are the identity provider's job.
|
||||||
if (revealPassword) await userEvent.click(screen.getByRole('button', { name: 'Sign in with a password instead' }))
|
|
||||||
const form = screen.getByRole('form', { name: 'Sign in' })
|
const form = screen.getByRole('form', { name: 'Sign in' })
|
||||||
expect(within(form).getByRole('button', { name: 'Sign In', exact: true })).toBeEnabled()
|
expect(within(form).getByRole('button', { name: 'Sign In', exact: true })).toBeEnabled()
|
||||||
await userEvent.type(within(form).getByLabelText('Email'), 'owner@example.test')
|
await userEvent.type(within(form).getByLabelText('Email'), 'owner@example.test')
|
||||||
|
|
@ -38,7 +37,7 @@ async function submitLogin({ revealPassword = false } = {}) {
|
||||||
|
|
||||||
it('logs in on the standalone page without rendering, loading or submitting a captcha', async () => {
|
it('logs in on the standalone page without rendering, loading or submitting a captcha', async () => {
|
||||||
mount(LoginPage)
|
mount(LoginPage)
|
||||||
await submitLogin({ revealPassword: true })
|
await submitLogin()
|
||||||
// Signing in is not a form a stranger can spam into existence — there is an
|
// Signing in is not a form a stranger can spam into existence — there is an
|
||||||
// account behind it and a rate limit in front of it.
|
// account behind it and a rate limit in front of it.
|
||||||
expect(document.querySelector('cap-widget')).toBeNull()
|
expect(document.querySelector('cap-widget')).toBeNull()
|
||||||
|
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
|
||||||
import userEvent from '@testing-library/user-event'
|
|
||||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
|
||||||
import { beforeEach, expect, it, vi } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
|
|
||||||
|
|
||||||
import LoginPage from './LoginPage'
|
|
||||||
import { AuthProvider } from '../context/AuthContext'
|
|
||||||
import api from '../api/client'
|
|
||||||
|
|
||||||
const SENT = 'If that address has an account, a sign-in code is on its way.'
|
|
||||||
const REFUSED = 'That code is not right, or it has expired. Request a new one.'
|
|
||||||
|
|
||||||
function answers({ sso = { sso_enabled: false }, policy = { registration_open: true } } = {}) {
|
|
||||||
api.get.mockImplementation(url => Promise.resolve({
|
|
||||||
data: url === '/auth/sso/config' ? sso
|
|
||||||
: url === '/auth/signup-policy' ? policy
|
|
||||||
: { id: 1, name: 'Test', role: 'user' },
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.resetAllMocks()
|
|
||||||
localStorage.clear()
|
|
||||||
answers()
|
|
||||||
api.post.mockResolvedValue({ data: { message: SENT } })
|
|
||||||
})
|
|
||||||
|
|
||||||
function mount() {
|
|
||||||
render(<MemoryRouter initialEntries={['/login']}><AuthProvider>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/login" element={<LoginPage />} />
|
|
||||||
<Route path="/" element={<div>Signed in successfully</div>} />
|
|
||||||
</Routes>
|
|
||||||
</AuthProvider></MemoryRouter>)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function askForCode(address = 'owner@example.test') {
|
|
||||||
await userEvent.type(screen.getByLabelText('Email'), address)
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Continue with email' }))
|
|
||||||
expect(api.post).toHaveBeenCalledWith('/auth/login-code', { email: address })
|
|
||||||
return screen.findByLabelText('Sign-in code')
|
|
||||||
}
|
|
||||||
|
|
||||||
it('sends a code and takes it on the same page', async () => {
|
|
||||||
mount()
|
|
||||||
expect(screen.queryByLabelText('Password')).not.toBeInTheDocument()
|
|
||||||
await askForCode()
|
|
||||||
expect(screen.getByText(SENT)).toBeInTheDocument()
|
|
||||||
// The address field is gone, so there is nothing left to fill in but the
|
|
||||||
// code — and nowhere for the sign-in to travel to.
|
|
||||||
expect(screen.queryByLabelText('Email')).not.toBeInTheDocument()
|
|
||||||
|
|
||||||
api.post.mockResolvedValue({ data: { access_token: 'synthetic-code-session' } })
|
|
||||||
await userEvent.type(screen.getByLabelText('Sign-in code'), 'abc def')
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Sign In', exact: true }))
|
|
||||||
expect(await screen.findByText('Signed in successfully')).toBeInTheDocument()
|
|
||||||
// Typed in whatever shape it was read in; the server does the normalising,
|
|
||||||
// and is sent the address so a code is only ever tried against one account.
|
|
||||||
expect(api.post).toHaveBeenCalledWith('/auth/login-code/verify',
|
|
||||||
{ email: 'owner@example.test', code: 'ABC DEF' })
|
|
||||||
expect(localStorage.getItem('token')).toBe('synthetic-code-session')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('repeats what the server said rather than claiming the mail was sent', async () => {
|
|
||||||
// The message is the one thing that must read the same for an address with
|
|
||||||
// an account and one without, so the page is not allowed a copy of its own.
|
|
||||||
api.post.mockResolvedValue({ data: { message: 'A differently worded but equally uncommitted answer.' } })
|
|
||||||
mount()
|
|
||||||
await askForCode('nobody@example.test')
|
|
||||||
expect(screen.getByText('A differently worded but equally uncommitted answer.')).toBeInTheDocument()
|
|
||||||
expect(screen.queryByText(/we sent|no account|not registered/i)).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps the code panel up when a code is refused', async () => {
|
|
||||||
mount()
|
|
||||||
await askForCode()
|
|
||||||
api.post.mockRejectedValue({ response: { status: 400, data: { detail: REFUSED } } })
|
|
||||||
await userEvent.type(screen.getByLabelText('Sign-in code'), 'ZZZZZZ')
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Sign In', exact: true }))
|
|
||||||
expect(await screen.findByText(REFUSED)).toBeInTheDocument()
|
|
||||||
// Still here to try again, and still on the address it was sent to.
|
|
||||||
expect(screen.getByLabelText('Sign-in code')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the reason when the site refuses to send another', async () => {
|
|
||||||
mount()
|
|
||||||
await askForCode()
|
|
||||||
api.post.mockRejectedValue({ response: { status: 429, data: { detail: 'Too many sign-in codes requested for that address. Please wait a few minutes.' } } })
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Send a new code' }))
|
|
||||||
expect(await screen.findByText(/Too many sign-in codes/)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('lets somebody go back and use a different address', async () => {
|
|
||||||
mount()
|
|
||||||
await askForCode()
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Use a different address' }))
|
|
||||||
expect(await screen.findByLabelText('Email')).toHaveValue('owner@example.test')
|
|
||||||
expect(screen.queryByLabelText('Sign-in code')).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps a password one click away for whoever has one', async () => {
|
|
||||||
api.post.mockResolvedValue({ data: { access_token: 'synthetic-login-token' } })
|
|
||||||
mount()
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Sign in with a password instead' }))
|
|
||||||
await userEvent.type(screen.getByLabelText('Email'), 'owner@example.test')
|
|
||||||
await userEvent.type(screen.getByLabelText('Password'), 'synthetic-password')
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Sign In', exact: true }))
|
|
||||||
expect(await screen.findByText('Signed in successfully')).toBeInTheDocument()
|
|
||||||
expect(api.post).toHaveBeenCalledWith('/auth/login', { email: 'owner@example.test', password: 'synthetic-password' })
|
|
||||||
expect(api.post).not.toHaveBeenCalledWith('/auth/login-code', expect.anything())
|
|
||||||
})
|
|
||||||
|
|
||||||
it('leaves single sign-on the first thing on the page', async () => {
|
|
||||||
answers({ sso: { sso_enabled: true, sso_only: false, provider_name: 'Test SSO' } })
|
|
||||||
mount()
|
|
||||||
const sso = await screen.findByRole('link', { name: 'Sign in with Test SSO' })
|
|
||||||
const byEmail = screen.getByRole('button', { name: 'Continue with email' })
|
|
||||||
expect(sso.compareDocumentPosition(byEmail) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('offers registration only while the site is open to it', async () => {
|
|
||||||
answers({ policy: { registration_open: false } })
|
|
||||||
mount()
|
|
||||||
expect(await screen.findByRole('button', { name: 'Continue with email' })).toBeInTheDocument()
|
|
||||||
await waitFor(() => expect(screen.queryByRole('link', { name: 'Sign up' })).not.toBeInTheDocument())
|
|
||||||
})
|
|
||||||
|
|
@ -3,22 +3,21 @@ import { useNavigate, Link } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
|
|
||||||
// Said by the server, and repeated here only if it says nothing. The wording
|
/**
|
||||||
// is load-bearing — it must not admit whether the address has an account — so
|
* Signing in.
|
||||||
// it lives in one place and this is not that place.
|
*
|
||||||
const SENT = 'If that address has an account, a sign-in code is on its way.'
|
* The provider first, and on a site that signs in only through one, the
|
||||||
|
* provider and nothing else. Email sign-in codes used to be the primary way in
|
||||||
|
* and lived here; codes are the identity provider's job now — it sends them,
|
||||||
|
* checks them, and knows about second factors — so the page that used to
|
||||||
|
* reimplement all of that offers a button instead.
|
||||||
|
*
|
||||||
|
* The password form survives for a site with no provider configured, or one
|
||||||
|
* that keeps both doors open.
|
||||||
|
*/
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [code, setCode] = useState('')
|
|
||||||
// A password is the second way in, not the first: most people do not have
|
|
||||||
// one worth remembering, and the ones who do are one click from it.
|
|
||||||
const [withPassword, setWithPassword] = useState(false)
|
|
||||||
// The code is typed on this page rather than followed from the mail, so the
|
|
||||||
// whole sign-in happens where it started — nothing to hand between devices,
|
|
||||||
// and no credential in anybody's address bar or history.
|
|
||||||
const [sent, setSent] = useState('')
|
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [unverified, setUnverified] = useState(false)
|
const [unverified, setUnverified] = useState(false)
|
||||||
|
|
@ -28,7 +27,7 @@ export default function LoginPage() {
|
||||||
// Null until asked, and treated as open while unknown: a slow answer must
|
// Null until asked, and treated as open while unknown: a slow answer must
|
||||||
// not hide a way in that exists.
|
// not hide a way in that exists.
|
||||||
const [policy, setPolicy] = useState(null)
|
const [policy, setPolicy] = useState(null)
|
||||||
const { login, loginWithToken } = useAuth()
|
const { login } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const searchParams = new URLSearchParams(window.location.search)
|
const searchParams = new URLSearchParams(window.location.search)
|
||||||
const ssoError = searchParams.get('error')
|
const ssoError = searchParams.get('error')
|
||||||
|
|
@ -38,74 +37,38 @@ export default function LoginPage() {
|
||||||
api.get('/auth/signup-policy').then(r => setPolicy(r.data)).catch(() => {})
|
api.get('/auth/signup-policy').then(r => setPolicy(r.data)).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const failed = (err, fallback) => {
|
|
||||||
const detail = err.response?.data?.detail
|
|
||||||
if (typeof detail === 'string') setError(detail)
|
|
||||||
else if (Array.isArray(detail)) setError(detail.some(e => e.loc?.includes('email')) ? 'Invalid email address.' : 'Please check your input and try again.')
|
|
||||||
else setError(fallback)
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestCode = async () => {
|
|
||||||
const res = await api.post('/auth/login-code', { email })
|
|
||||||
setCode('')
|
|
||||||
setSent(res.data?.message || SENT)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
setUnverified(false)
|
setUnverified(false)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
if (withPassword) {
|
await login(email, password)
|
||||||
await login(email, password)
|
navigate('/')
|
||||||
navigate('/')
|
|
||||||
} else if (sent) {
|
|
||||||
const res = await api.post('/auth/login-code/verify', { email, code })
|
|
||||||
await loginWithToken(res.data.access_token)
|
|
||||||
navigate('/')
|
|
||||||
} else {
|
|
||||||
await requestCode()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (withPassword && err.response?.status === 403) {
|
if (err.response?.status === 403) {
|
||||||
setUnverified(true)
|
setUnverified(true)
|
||||||
} else {
|
} else {
|
||||||
failed(err, withPassword ? 'Login failed' : 'Something went wrong. Please try again.')
|
const detail = err.response?.data?.detail
|
||||||
|
setError(typeof detail === 'string' ? detail : 'Login failed')
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resend = async () => {
|
|
||||||
setError('')
|
|
||||||
setResending(true)
|
|
||||||
try { await requestCode() }
|
|
||||||
catch (err) { failed(err, 'Could not send another code. Please try again.') }
|
|
||||||
finally { setResending(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const resendVerification = async () => {
|
const resendVerification = async () => {
|
||||||
setResending(true)
|
setResending(true)
|
||||||
try {
|
try {
|
||||||
await api.post('/auth/resend-verification', { email })
|
await api.post('/auth/resend-verification', { email })
|
||||||
setResendSent(true)
|
|
||||||
} catch {
|
} catch {
|
||||||
setResendSent(true) // always show success to avoid enumeration
|
// Always shown as sent, so this cannot be used to learn who has an account.
|
||||||
} finally {
|
} finally {
|
||||||
|
setResendSent(true)
|
||||||
setResending(false)
|
setResending(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const startOver = () => { setSent(''); setCode(''); setError('') }
|
|
||||||
const usePassword = () => { startOver(); setWithPassword(true) }
|
|
||||||
|
|
||||||
const linkButton = {
|
|
||||||
background: 'none', border: 'none', color: 'var(--primary)',
|
|
||||||
cursor: 'pointer', padding: 0, fontSize: '0.85rem',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="auth-page">
|
<div className="auth-page">
|
||||||
<div className="auth-card">
|
<div className="auth-card">
|
||||||
|
|
@ -133,7 +96,7 @@ export default function LoginPage() {
|
||||||
|
|
||||||
{ssoConfig?.sso_enabled && (
|
{ssoConfig?.sso_enabled && (
|
||||||
<>
|
<>
|
||||||
<a href="/api/auth/sso/login" className="btn btn-secondary" style={{ width: '100%', display: 'block', textAlign: 'center', textDecoration: 'none', marginBottom: 16 }}>
|
<a href="/api/auth/sso/login" className="btn btn-primary" style={{ width: '100%', display: 'block', textAlign: 'center', textDecoration: 'none', marginBottom: 16 }}>
|
||||||
Sign in with {ssoConfig.provider_name}
|
Sign in with {ssoConfig.provider_name}
|
||||||
</a>
|
</a>
|
||||||
{!ssoConfig.sso_only && <div style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.82rem', margin: '12px 0' }}>or sign in with email</div>}
|
{!ssoConfig.sso_only && <div style={{ textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.82rem', margin: '12px 0' }}>or sign in with email</div>}
|
||||||
|
|
@ -143,60 +106,24 @@ export default function LoginPage() {
|
||||||
{!ssoConfig?.sso_only && (
|
{!ssoConfig?.sso_only && (
|
||||||
<>
|
<>
|
||||||
<form aria-label="Sign in" onSubmit={handleSubmit}>
|
<form aria-label="Sign in" onSubmit={handleSubmit}>
|
||||||
{sent && !withPassword ? (
|
<div className="form-group">
|
||||||
<>
|
<label htmlFor="login-email">Email</label>
|
||||||
<p style={{ color: '#64748b', fontSize: '0.9rem', margin: '0 0 4px' }}>{sent}</p>
|
<input id="login-email" type="email" autoComplete="username" value={email}
|
||||||
<p style={{ color: '#64748b', fontSize: '0.82rem', margin: '0 0 16px' }}>
|
onChange={e => setEmail(e.target.value)} required />
|
||||||
It expires in 15 minutes. Check your spam folder if it isn't there.
|
</div>
|
||||||
</p>
|
<div className="form-group">
|
||||||
<div className="form-group">
|
<label htmlFor="login-password">Password</label>
|
||||||
<label htmlFor="login-code">Sign-in code</label>
|
<input id="login-password" type="password" autoComplete="current-password"
|
||||||
{/* Upper-cased as it is typed, as the invite field is. What
|
value={password} onChange={e => setPassword(e.target.value)} required />
|
||||||
the server compares is normalised anyway: case, spaces
|
</div>
|
||||||
and dashes are not part of the code. */}
|
|
||||||
<input id="login-code" value={code} required autoFocus autoComplete="one-time-code"
|
|
||||||
placeholder="ABC DEF" maxLength={12}
|
|
||||||
style={{ textTransform: 'uppercase', letterSpacing: '0.18em', fontSize: '1.1rem' }}
|
|
||||||
onChange={e => setCode(e.target.value.toUpperCase())} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="login-email">Email</label>
|
|
||||||
<input id="login-email" type="email" autoComplete="username" value={email} onChange={e => setEmail(e.target.value)} required />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{withPassword && (
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="login-password">Password</label>
|
|
||||||
<input id="login-password" type="password" autoComplete="current-password" autoFocus value={password} onChange={e => setPassword(e.target.value)} required />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
|
||||||
{withPassword ? (loading ? 'Signing in...' : 'Sign In')
|
{loading ? 'Signing in...' : 'Sign In'}
|
||||||
: sent ? (loading ? 'Signing in...' : 'Sign In')
|
|
||||||
: (loading ? 'Sending...' : 'Continue with email')}
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{withPassword ? (
|
<div className="auth-link">
|
||||||
<div className="auth-link" style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
|
<Link to="/forgot-password" style={{ color: '#64748b', fontSize: '0.85rem' }}>Forgot password?</Link>
|
||||||
<button onClick={() => setWithPassword(false)} style={linkButton}>Email me a code instead</button>
|
</div>
|
||||||
<Link to="/forgot-password" style={{ color: '#64748b', fontSize: '0.85rem' }}>Forgot password?</Link>
|
|
||||||
</div>
|
|
||||||
) : sent ? (
|
|
||||||
<div className="auth-link" style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
|
|
||||||
<button onClick={resend} disabled={resending} style={linkButton}>
|
|
||||||
{resending ? 'Sending…' : 'Send a new code'}
|
|
||||||
</button>
|
|
||||||
<button onClick={startOver} style={linkButton}>Use a different address</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="auth-link">
|
|
||||||
{/* No mail to wait for if you already have a password. */}
|
|
||||||
<button onClick={usePassword} style={linkButton}>Sign in with a password instead</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Only where there is a door. Registration can be turned off
|
{/* Only where there is a door. Registration can be turned off
|
||||||
site-wide, and this offered a locked one — the only way to find
|
site-wide, and this offered a locked one — the only way to find
|
||||||
|
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
|
||||||
import userEvent from '@testing-library/user-event'
|
|
||||||
import { MemoryRouter } from 'react-router-dom'
|
|
||||||
import RegisterPage from './RegisterPage'
|
|
||||||
import api from '../api/client'
|
|
||||||
|
|
||||||
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn() } }))
|
|
||||||
vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ loginWithToken: vi.fn() }) }))
|
|
||||||
|
|
||||||
const mount = (inviteRequired) => {
|
|
||||||
api.get.mockResolvedValue({ data: { invite_required: inviteRequired, first_user: false } })
|
|
||||||
return render(<MemoryRouter><RegisterPage /></MemoryRouter>)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fill = async () => {
|
|
||||||
await userEvent.type(screen.getByLabelText('Name'), 'Ada')
|
|
||||||
await userEvent.type(screen.getByLabelText('Email'), 'ada@example.test')
|
|
||||||
await userEvent.type(screen.getByLabelText('Password'), 'longenough1')
|
|
||||||
// Typed twice, as a real person would.
|
|
||||||
await userEvent.type(screen.getByLabelText('Confirm password'), 'longenough1')
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('registering when the site is invite only', () => {
|
|
||||||
beforeEach(() => { vi.clearAllMocks() })
|
|
||||||
|
|
||||||
it('asks for nothing extra on an open site', async () => {
|
|
||||||
mount(false)
|
|
||||||
await waitFor(() => expect(api.get).toHaveBeenCalledWith('/auth/signup-policy'))
|
|
||||||
expect(screen.queryByLabelText('Invite code')).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('asks for a code, and will not submit without one', async () => {
|
|
||||||
mount(true)
|
|
||||||
const field = await screen.findByLabelText('Invite code')
|
|
||||||
await fill()
|
|
||||||
expect(screen.getByRole('button', { name: /Sign Up/ })).toBeDisabled()
|
|
||||||
|
|
||||||
await userEvent.type(field, 'abcd234xyz')
|
|
||||||
// Typed in whatever case, sent in the one the codes are issued in.
|
|
||||||
expect(field).toHaveValue('ABCD234XYZ')
|
|
||||||
expect(screen.getByRole('button', { name: /Sign Up/ })).toBeEnabled()
|
|
||||||
|
|
||||||
api.post.mockResolvedValue({ data: { requires_verification: true } })
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: /Sign Up/ }))
|
|
||||||
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/auth/register',
|
|
||||||
expect.objectContaining({ invite_code: 'ABCD234XYZ' })))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the refusal rather than a blank failure', async () => {
|
|
||||||
mount(true)
|
|
||||||
await screen.findByLabelText('Invite code')
|
|
||||||
await fill()
|
|
||||||
await userEvent.type(screen.getByLabelText('Invite code'), 'WRONGCODE1')
|
|
||||||
api.post.mockRejectedValue({ response: { data: { detail: 'This site is invite-only. A valid invite code is required.' } } })
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: /Sign Up/ }))
|
|
||||||
expect(await screen.findByText(/invite-only/)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('stays usable if the policy cannot be fetched', async () => {
|
|
||||||
api.get.mockRejectedValue(new Error('down'))
|
|
||||||
render(<MemoryRouter><RegisterPage /></MemoryRouter>)
|
|
||||||
// No code asked for, rather than a form nobody can complete.
|
|
||||||
await waitFor(() => expect(screen.queryByLabelText('Invite code')).not.toBeInTheDocument())
|
|
||||||
await fill()
|
|
||||||
expect(screen.getByRole('button', { name: /Sign Up/ })).toBeEnabled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -15,13 +15,9 @@ export default function RegisterPage() {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [done, setDone] = useState(false)
|
const [done, setDone] = useState(false)
|
||||||
const [captchaToken, setCaptchaToken] = useState('')
|
const [captchaToken, setCaptchaToken] = useState('')
|
||||||
// Whether this site is invite-only. Asked before there is an account to ask
|
|
||||||
// with, so the form knows whether to want a code.
|
|
||||||
const [inviteRequired, setInviteRequired] = useState(false)
|
|
||||||
const [inviteCode, setInviteCode] = useState('')
|
|
||||||
//: Null until the policy is known. A site on single sign-on has no password
|
//: Null until the policy is known. A site on single sign-on has no password
|
||||||
//: sign-up, and drawing the form and refusing the POST is asking somebody
|
//: sign-up, and drawing the form and refusing the POST is asking somebody
|
||||||
//: for a name, an email, a password twice and an invite code before telling
|
//: for a name, an email and a password twice before telling
|
||||||
//: them the door does not exist.
|
//: them the door does not exist.
|
||||||
const [policy, setPolicy] = useState(null)
|
const [policy, setPolicy] = useState(null)
|
||||||
const { loginWithToken } = useAuth()
|
const { loginWithToken } = useAuth()
|
||||||
|
|
@ -31,7 +27,6 @@ export default function RegisterPage() {
|
||||||
api.get('/auth/signup-policy')
|
api.get('/auth/signup-policy')
|
||||||
.then(res => {
|
.then(res => {
|
||||||
if (!live) return
|
if (!live) return
|
||||||
setInviteRequired(!!res.data?.invite_required)
|
|
||||||
setPolicy(res.data || {})
|
setPolicy(res.data || {})
|
||||||
})
|
})
|
||||||
.catch(() => { if (live) setPolicy({}) })
|
.catch(() => { if (live) setPolicy({}) })
|
||||||
|
|
@ -47,7 +42,6 @@ export default function RegisterPage() {
|
||||||
const res = await api.post('/auth/register', {
|
const res = await api.post('/auth/register', {
|
||||||
email, password, name,
|
email, password, name,
|
||||||
captcha_token: captchaToken || null,
|
captcha_token: captchaToken || null,
|
||||||
invite_code: inviteCode.trim() || null,
|
|
||||||
})
|
})
|
||||||
if (res.data.requires_verification) {
|
if (res.data.requires_verification) {
|
||||||
setDone(true)
|
setDone(true)
|
||||||
|
|
@ -140,25 +134,10 @@ export default function RegisterPage() {
|
||||||
</small>
|
</small>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{inviteRequired && (
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="invite-code">Invite code</label>
|
|
||||||
{/* Asked for only where it is needed. The form never says whether
|
|
||||||
a code is valid before the account is made — that would be a
|
|
||||||
place to guess them. */}
|
|
||||||
<input id="invite-code" value={inviteCode} required autoComplete="off"
|
|
||||||
placeholder="From whoever invited you"
|
|
||||||
style={{ textTransform: 'uppercase', letterSpacing: '0.08em' }}
|
|
||||||
onChange={e => setInviteCode(e.target.value.toUpperCase())} />
|
|
||||||
<small style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>
|
|
||||||
This site is invite only.
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Captcha onVerify={setCaptchaToken} />
|
<Captcha onVerify={setCaptchaToken} />
|
||||||
<button className="btn btn-primary" style={{ width: '100%' }}
|
<button className="btn btn-primary" style={{ width: '100%' }}
|
||||||
disabled={loading || !password || password !== confirm
|
disabled={loading || !password || password !== confirm
|
||||||
|| (captchaSiteKey() && !captchaToken) || (inviteRequired && !inviteCode.trim())}>
|
|| (captchaSiteKey() && !captchaToken)}>
|
||||||
{loading ? 'Creating account...' : 'Sign Up'}
|
{loading ? 'Creating account...' : 'Sign Up'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue