Move auth server from federation to account
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
Account is now the OAuth authorization server with magic link login, OAuth2 authorize endpoint, SSO logout, and session management. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
12
app.py
12
app.py
@@ -1,12 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import path_setup # noqa: F401 # adds shared/ to sys.path
|
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import g
|
from quart import g
|
||||||
|
from jinja2 import FileSystemLoader, ChoiceLoader
|
||||||
|
|
||||||
from shared.infrastructure.factory import create_base_app
|
from shared.infrastructure.factory import create_base_app
|
||||||
from shared.services.registry import services
|
from shared.services.registry import services
|
||||||
|
|
||||||
from bp import register_account_bp
|
from bp import register_account_bp, register_auth_bp
|
||||||
|
|
||||||
|
|
||||||
async def account_context() -> dict:
|
async def account_context() -> dict:
|
||||||
@@ -39,7 +41,15 @@ def create_app() -> "Quart":
|
|||||||
domain_services_fn=register_domain_services,
|
domain_services_fn=register_domain_services,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# App-specific templates override shared templates
|
||||||
|
app_templates = str(Path(__file__).resolve().parent / "templates")
|
||||||
|
app.jinja_loader = ChoiceLoader([
|
||||||
|
FileSystemLoader(app_templates),
|
||||||
|
app.jinja_loader,
|
||||||
|
])
|
||||||
|
|
||||||
# --- blueprints ---
|
# --- blueprints ---
|
||||||
|
app.register_blueprint(register_auth_bp())
|
||||||
app.register_blueprint(register_account_bp())
|
app.register_blueprint(register_account_bp())
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
0
blog/__init__.py
Normal file
0
blog/__init__.py
Normal file
14
blog/models/__init__.py
Normal file
14
blog/models/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from .ghost_content import Post, Author, Tag, PostAuthor, PostTag, PostLike
|
||||||
|
from .snippet import Snippet
|
||||||
|
from .tag_group import TagGroup, TagGroupTag
|
||||||
|
|
||||||
|
# Shared models — canonical definitions live in shared/models/
|
||||||
|
from shared.models.ghost_membership_entities import (
|
||||||
|
GhostLabel, UserLabel,
|
||||||
|
GhostNewsletter, UserNewsletter,
|
||||||
|
GhostTier, GhostSubscription,
|
||||||
|
)
|
||||||
|
from shared.models.menu_item import MenuItem
|
||||||
|
from shared.models.kv import KV
|
||||||
|
from shared.models.magic_link import MagicLink
|
||||||
|
from shared.models.user import User
|
||||||
3
blog/models/ghost_content.py
Normal file
3
blog/models/ghost_content.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from shared.models.ghost_content import ( # noqa: F401
|
||||||
|
Tag, Post, Author, PostAuthor, PostTag, PostLike,
|
||||||
|
)
|
||||||
12
blog/models/ghost_membership_entities.py
Normal file
12
blog/models/ghost_membership_entities.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Re-export from canonical shared location
|
||||||
|
from shared.models.ghost_membership_entities import (
|
||||||
|
GhostLabel, UserLabel,
|
||||||
|
GhostNewsletter, UserNewsletter,
|
||||||
|
GhostTier, GhostSubscription,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"GhostLabel", "UserLabel",
|
||||||
|
"GhostNewsletter", "UserNewsletter",
|
||||||
|
"GhostTier", "GhostSubscription",
|
||||||
|
]
|
||||||
4
blog/models/kv.py
Normal file
4
blog/models/kv.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Re-export from canonical shared location
|
||||||
|
from shared.models.kv import KV
|
||||||
|
|
||||||
|
__all__ = ["KV"]
|
||||||
4
blog/models/magic_link.py
Normal file
4
blog/models/magic_link.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Re-export from canonical shared location
|
||||||
|
from shared.models.magic_link import MagicLink
|
||||||
|
|
||||||
|
__all__ = ["MagicLink"]
|
||||||
4
blog/models/menu_item.py
Normal file
4
blog/models/menu_item.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Re-export from canonical shared location
|
||||||
|
from shared.models.menu_item import MenuItem
|
||||||
|
|
||||||
|
__all__ = ["MenuItem"]
|
||||||
32
blog/models/snippet.py
Normal file
32
blog/models/snippet.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Integer, String, Text, DateTime, ForeignKey, UniqueConstraint, Index, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from shared.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Snippet(Base):
|
||||||
|
__tablename__ = "snippets"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "name", name="uq_snippets_user_name"),
|
||||||
|
Index("ix_snippets_visibility", "visibility"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
value: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
visibility: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="private", server_default="private",
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(),
|
||||||
|
)
|
||||||
52
blog/models/tag_group.py
Normal file
52
blog/models/tag_group.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from sqlalchemy import (
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
UniqueConstraint,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from shared.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class TagGroup(Base):
|
||||||
|
__tablename__ = "tag_groups"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
slug: Mapped[str] = mapped_column(String(191), unique=True, nullable=False)
|
||||||
|
feature_image: Mapped[Optional[str]] = mapped_column(Text())
|
||||||
|
colour: Mapped[Optional[str]] = mapped_column(String(32))
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
tag_links: Mapped[List["TagGroupTag"]] = relationship(
|
||||||
|
"TagGroupTag", back_populates="group", cascade="all, delete-orphan", passive_deletes=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TagGroupTag(Base):
|
||||||
|
__tablename__ = "tag_group_tags"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tag_group_id", "tag_id", name="uq_tag_group_tag"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
tag_group_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("tag_groups.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
tag_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("tags.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
group: Mapped["TagGroup"] = relationship("TagGroup", back_populates="tag_links")
|
||||||
4
blog/models/user.py
Normal file
4
blog/models/user.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Re-export from canonical shared location
|
||||||
|
from shared.models.user import User
|
||||||
|
|
||||||
|
__all__ = ["User"]
|
||||||
@@ -1 +1,2 @@
|
|||||||
from .account.routes import register as register_account_bp
|
from .account.routes import register as register_account_bp
|
||||||
|
from .auth.routes import register as register_auth_bp
|
||||||
|
|||||||
0
bp/auth/__init__.py
Normal file
0
bp/auth/__init__.py
Normal file
232
bp/auth/routes.py
Normal file
232
bp/auth/routes.py
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
"""Authentication routes for the account app.
|
||||||
|
|
||||||
|
Account is the OAuth authorization server. Owns magic link login/logout,
|
||||||
|
OAuth2 authorize endpoint, and SSO logout.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
from quart import (
|
||||||
|
Blueprint,
|
||||||
|
request,
|
||||||
|
render_template,
|
||||||
|
redirect,
|
||||||
|
url_for,
|
||||||
|
session as qsession,
|
||||||
|
g,
|
||||||
|
current_app,
|
||||||
|
)
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
from shared.db.session import get_session
|
||||||
|
from shared.models import User
|
||||||
|
from shared.models.oauth_code import OAuthCode
|
||||||
|
from shared.infrastructure.urls import account_url, app_url
|
||||||
|
from shared.infrastructure.cart_identity import current_cart_identity
|
||||||
|
from shared.events import emit_activity
|
||||||
|
|
||||||
|
from .services import (
|
||||||
|
pop_login_redirect_target,
|
||||||
|
store_login_redirect_target,
|
||||||
|
send_magic_email,
|
||||||
|
find_or_create_user,
|
||||||
|
create_magic_link,
|
||||||
|
validate_magic_link,
|
||||||
|
validate_email,
|
||||||
|
)
|
||||||
|
|
||||||
|
SESSION_USER_KEY = "uid"
|
||||||
|
|
||||||
|
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "federation"}
|
||||||
|
|
||||||
|
|
||||||
|
def register(url_prefix="/auth"):
|
||||||
|
auth_bp = Blueprint("auth", __name__, url_prefix=url_prefix)
|
||||||
|
|
||||||
|
# --- OAuth2 authorize endpoint -------------------------------------------
|
||||||
|
|
||||||
|
@auth_bp.get("/oauth/authorize")
|
||||||
|
@auth_bp.get("/oauth/authorize/")
|
||||||
|
async def oauth_authorize():
|
||||||
|
client_id = request.args.get("client_id", "")
|
||||||
|
redirect_uri = request.args.get("redirect_uri", "")
|
||||||
|
state = request.args.get("state", "")
|
||||||
|
|
||||||
|
if client_id not in ALLOWED_CLIENTS:
|
||||||
|
return "Invalid client_id", 400
|
||||||
|
|
||||||
|
expected_redirect = app_url(client_id, "/auth/callback")
|
||||||
|
if redirect_uri != expected_redirect:
|
||||||
|
return "Invalid redirect_uri", 400
|
||||||
|
|
||||||
|
# Not logged in — bounce to magic link login, then back here
|
||||||
|
if not g.get("user"):
|
||||||
|
# Preserve the full authorize URL so we return here after login
|
||||||
|
authorize_path = request.full_path # includes query string
|
||||||
|
store_login_redirect_target()
|
||||||
|
return redirect(url_for("auth.login_form", next=authorize_path))
|
||||||
|
|
||||||
|
# Logged in — issue authorization code
|
||||||
|
code = secrets.token_urlsafe(48)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
expires = now + timedelta(minutes=5)
|
||||||
|
|
||||||
|
async with get_session() as s:
|
||||||
|
async with s.begin():
|
||||||
|
oauth_code = OAuthCode(
|
||||||
|
code=code,
|
||||||
|
user_id=g.user.id,
|
||||||
|
client_id=client_id,
|
||||||
|
redirect_uri=redirect_uri,
|
||||||
|
expires_at=expires,
|
||||||
|
)
|
||||||
|
s.add(oauth_code)
|
||||||
|
|
||||||
|
sep = "&" if "?" in redirect_uri else "?"
|
||||||
|
return redirect(f"{redirect_uri}{sep}code={code}&state={state}")
|
||||||
|
|
||||||
|
# --- Magic link login flow -----------------------------------------------
|
||||||
|
|
||||||
|
@auth_bp.get("/login/")
|
||||||
|
async def login_form():
|
||||||
|
store_login_redirect_target()
|
||||||
|
cross_cart_sid = request.args.get("cart_sid")
|
||||||
|
if cross_cart_sid:
|
||||||
|
qsession["cart_sid"] = cross_cart_sid
|
||||||
|
if g.get("user"):
|
||||||
|
# If there's a pending redirect (e.g. OAuth authorize), follow it
|
||||||
|
redirect_url = pop_login_redirect_target()
|
||||||
|
return redirect(redirect_url)
|
||||||
|
return await render_template("auth/login.html")
|
||||||
|
|
||||||
|
@auth_bp.post("/start/")
|
||||||
|
async def start_login():
|
||||||
|
form = await request.form
|
||||||
|
email_input = form.get("email") or ""
|
||||||
|
|
||||||
|
is_valid, email = validate_email(email_input)
|
||||||
|
if not is_valid:
|
||||||
|
return (
|
||||||
|
await render_template(
|
||||||
|
"auth/login.html",
|
||||||
|
error="Please enter a valid email address.",
|
||||||
|
email=email_input,
|
||||||
|
),
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
|
||||||
|
user = await find_or_create_user(g.s, email)
|
||||||
|
token, expires = await create_magic_link(g.s, user.id)
|
||||||
|
|
||||||
|
from shared.utils import host_url
|
||||||
|
magic_url = host_url(url_for("auth.magic", token=token))
|
||||||
|
|
||||||
|
email_error = None
|
||||||
|
try:
|
||||||
|
await send_magic_email(email, magic_url)
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error("EMAIL SEND FAILED: %r", e)
|
||||||
|
email_error = (
|
||||||
|
"We couldn't send the email automatically. "
|
||||||
|
"Please try again in a moment."
|
||||||
|
)
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"auth/check_email.html",
|
||||||
|
email=email,
|
||||||
|
email_error=email_error,
|
||||||
|
)
|
||||||
|
|
||||||
|
@auth_bp.get("/magic/<token>/")
|
||||||
|
async def magic(token: str):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
user_id: int | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with get_session() as s:
|
||||||
|
async with s.begin():
|
||||||
|
user, error = await validate_magic_link(s, token)
|
||||||
|
|
||||||
|
if error:
|
||||||
|
return (
|
||||||
|
await render_template("auth/login.html", error=error),
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
user_id = user.id
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return (
|
||||||
|
await render_template(
|
||||||
|
"auth/login.html",
|
||||||
|
error="Could not sign you in right now. Please try again.",
|
||||||
|
),
|
||||||
|
502,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert user_id is not None
|
||||||
|
|
||||||
|
ident = current_cart_identity()
|
||||||
|
anon_session_id = ident.get("session_id")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with get_session() as s:
|
||||||
|
async with s.begin():
|
||||||
|
u2 = await s.get(User, user_id)
|
||||||
|
if u2:
|
||||||
|
u2.last_login_at = now
|
||||||
|
if anon_session_id:
|
||||||
|
await emit_activity(
|
||||||
|
s,
|
||||||
|
activity_type="rose:Login",
|
||||||
|
actor_uri="internal:system",
|
||||||
|
object_type="Person",
|
||||||
|
object_data={
|
||||||
|
"user_id": user_id,
|
||||||
|
"session_id": anon_session_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except SQLAlchemyError:
|
||||||
|
current_app.logger.exception(
|
||||||
|
"[auth] non-fatal DB update for user_id=%s", user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
qsession[SESSION_USER_KEY] = user_id
|
||||||
|
|
||||||
|
redirect_url = pop_login_redirect_target()
|
||||||
|
resp = redirect(redirect_url, 303)
|
||||||
|
resp.set_cookie(
|
||||||
|
"sso_hint", "1",
|
||||||
|
domain=".rose-ash.com", max_age=30 * 24 * 3600,
|
||||||
|
secure=True, samesite="Lax", httponly=True,
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@auth_bp.post("/logout/")
|
||||||
|
async def logout():
|
||||||
|
qsession.pop(SESSION_USER_KEY, None)
|
||||||
|
resp = redirect(account_url("/"))
|
||||||
|
resp.delete_cookie("sso_hint", domain=".rose-ash.com", path="/")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@auth_bp.get("/clear/")
|
||||||
|
async def clear():
|
||||||
|
"""One-time migration helper: clear all session cookies."""
|
||||||
|
qsession.clear()
|
||||||
|
resp = redirect(account_url("/"))
|
||||||
|
resp.delete_cookie("blog_session", domain=".rose-ash.com", path="/")
|
||||||
|
resp.delete_cookie("sso_hint", domain=".rose-ash.com", path="/")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@auth_bp.get("/sso-logout/")
|
||||||
|
async def sso_logout():
|
||||||
|
"""SSO logout: clear account session + sso_hint, redirect to blog."""
|
||||||
|
qsession.pop(SESSION_USER_KEY, None)
|
||||||
|
from shared.infrastructure.urls import blog_url
|
||||||
|
resp = redirect(blog_url("/"))
|
||||||
|
resp.delete_cookie("sso_hint", domain=".rose-ash.com", path="/")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
return auth_bp
|
||||||
24
bp/auth/services/__init__.py
Normal file
24
bp/auth/services/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from .login_redirect import pop_login_redirect_target, store_login_redirect_target
|
||||||
|
from .auth_operations import (
|
||||||
|
get_app_host,
|
||||||
|
get_app_root,
|
||||||
|
send_magic_email,
|
||||||
|
load_user_by_id,
|
||||||
|
find_or_create_user,
|
||||||
|
create_magic_link,
|
||||||
|
validate_magic_link,
|
||||||
|
validate_email,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"pop_login_redirect_target",
|
||||||
|
"store_login_redirect_target",
|
||||||
|
"get_app_host",
|
||||||
|
"get_app_root",
|
||||||
|
"send_magic_email",
|
||||||
|
"load_user_by_id",
|
||||||
|
"find_or_create_user",
|
||||||
|
"create_magic_link",
|
||||||
|
"validate_magic_link",
|
||||||
|
"validate_email",
|
||||||
|
]
|
||||||
156
bp/auth/services/auth_operations.py
Normal file
156
bp/auth/services/auth_operations.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""Auth operations for the account app.
|
||||||
|
|
||||||
|
Owns magic-link login. Shared models, shared config.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from quart import current_app, render_template, request, g
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from shared.models import User, MagicLink
|
||||||
|
from shared.config import config
|
||||||
|
|
||||||
|
|
||||||
|
def get_app_host() -> str:
|
||||||
|
host = (
|
||||||
|
config().get("host") or os.getenv("APP_HOST") or "http://localhost:8000"
|
||||||
|
).rstrip("/")
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
|
def get_app_root() -> str:
|
||||||
|
root = (g.root).rstrip("/")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
async def send_magic_email(to_email: str, link_url: str) -> None:
|
||||||
|
host = os.getenv("SMTP_HOST")
|
||||||
|
port = int(os.getenv("SMTP_PORT") or "587")
|
||||||
|
username = os.getenv("SMTP_USER")
|
||||||
|
password = os.getenv("SMTP_PASS")
|
||||||
|
mail_from = os.getenv("MAIL_FROM") or "no-reply@example.com"
|
||||||
|
|
||||||
|
site_name = config().get("title", "Rose Ash")
|
||||||
|
subject = f"Your sign-in link \u2014 {site_name}"
|
||||||
|
|
||||||
|
tpl_vars = dict(site_name=site_name, link_url=link_url)
|
||||||
|
text_body = await render_template("_email/magic_link.txt", **tpl_vars)
|
||||||
|
html_body = await render_template("_email/magic_link.html", **tpl_vars)
|
||||||
|
|
||||||
|
if not host or not username or not password:
|
||||||
|
current_app.logger.warning(
|
||||||
|
"SMTP not configured. Printing magic link to console for %s: %s",
|
||||||
|
to_email,
|
||||||
|
link_url,
|
||||||
|
)
|
||||||
|
print(f"[DEV] Magic link for {to_email}: {link_url}")
|
||||||
|
return
|
||||||
|
|
||||||
|
import aiosmtplib
|
||||||
|
from email.message import EmailMessage
|
||||||
|
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["From"] = mail_from
|
||||||
|
msg["To"] = to_email
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg.set_content(text_body)
|
||||||
|
msg.add_alternative(html_body, subtype="html")
|
||||||
|
|
||||||
|
is_secure = port == 465
|
||||||
|
if is_secure:
|
||||||
|
smtp = aiosmtplib.SMTP(
|
||||||
|
hostname=host, port=port, use_tls=True,
|
||||||
|
username=username, password=password,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
smtp = aiosmtplib.SMTP(
|
||||||
|
hostname=host, port=port, start_tls=True,
|
||||||
|
username=username, password=password,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with smtp:
|
||||||
|
await smtp.send_message(msg)
|
||||||
|
|
||||||
|
|
||||||
|
async def load_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
|
||||||
|
stmt = (
|
||||||
|
select(User)
|
||||||
|
.options(selectinload(User.labels))
|
||||||
|
.where(User.id == user_id)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def find_or_create_user(session: AsyncSession, email: str) -> User:
|
||||||
|
result = await session.execute(select(User).where(User.email == email))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
user = User(email=email)
|
||||||
|
session.add(user)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def create_magic_link(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
purpose: str = "signin",
|
||||||
|
expires_minutes: int = 15,
|
||||||
|
) -> Tuple[str, datetime]:
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
expires = datetime.now(timezone.utc) + timedelta(minutes=expires_minutes)
|
||||||
|
|
||||||
|
ml = MagicLink(
|
||||||
|
token=token,
|
||||||
|
user_id=user_id,
|
||||||
|
purpose=purpose,
|
||||||
|
expires_at=expires,
|
||||||
|
ip=request.headers.get("x-forwarded-for", request.remote_addr),
|
||||||
|
user_agent=request.headers.get("user-agent"),
|
||||||
|
)
|
||||||
|
session.add(ml)
|
||||||
|
|
||||||
|
return token, expires
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_magic_link(
|
||||||
|
session: AsyncSession,
|
||||||
|
token: str,
|
||||||
|
) -> Tuple[Optional[User], Optional[str]]:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
ml = await session.scalar(
|
||||||
|
select(MagicLink)
|
||||||
|
.where(MagicLink.token == token)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not ml or ml.purpose != "signin":
|
||||||
|
return None, "Invalid or expired link."
|
||||||
|
|
||||||
|
if ml.used_at or ml.expires_at < now:
|
||||||
|
return None, "This link has expired. Please request a new one."
|
||||||
|
|
||||||
|
user = await session.get(User, ml.user_id)
|
||||||
|
if not user:
|
||||||
|
return None, "User not found."
|
||||||
|
|
||||||
|
ml.used_at = now
|
||||||
|
return user, None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_email(email: str) -> Tuple[bool, str]:
|
||||||
|
email = email.strip().lower()
|
||||||
|
if not email or "@" not in email:
|
||||||
|
return False, email
|
||||||
|
return True, email
|
||||||
45
bp/auth/services/login_redirect.py
Normal file
45
bp/auth/services/login_redirect.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from urllib.parse import urlparse
|
||||||
|
from quart import session
|
||||||
|
|
||||||
|
from shared.infrastructure.urls import account_url
|
||||||
|
|
||||||
|
|
||||||
|
LOGIN_REDIRECT_SESSION_KEY = "login_redirect_to"
|
||||||
|
|
||||||
|
|
||||||
|
def store_login_redirect_target() -> None:
|
||||||
|
from quart import request
|
||||||
|
|
||||||
|
target = request.args.get("next")
|
||||||
|
if not target:
|
||||||
|
ref = request.referrer or ""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(ref)
|
||||||
|
target = parsed.path or ""
|
||||||
|
except Exception:
|
||||||
|
target = ""
|
||||||
|
|
||||||
|
if not target:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Accept both relative paths and absolute URLs (cross-app redirects)
|
||||||
|
if target.startswith("http://") or target.startswith("https://"):
|
||||||
|
session[LOGIN_REDIRECT_SESSION_KEY] = target
|
||||||
|
elif target.startswith("/") and not target.startswith("//"):
|
||||||
|
session[LOGIN_REDIRECT_SESSION_KEY] = target
|
||||||
|
|
||||||
|
|
||||||
|
def pop_login_redirect_target() -> str:
|
||||||
|
path = session.pop(LOGIN_REDIRECT_SESSION_KEY, None)
|
||||||
|
if not path or not isinstance(path, str):
|
||||||
|
return account_url("/")
|
||||||
|
|
||||||
|
# Absolute URL: return as-is (cross-app redirect)
|
||||||
|
if path.startswith("http://") or path.startswith("https://"):
|
||||||
|
return path
|
||||||
|
|
||||||
|
# Relative path: must start with / and not //
|
||||||
|
if path.startswith("/") and not path.startswith("//"):
|
||||||
|
return account_url(path)
|
||||||
|
|
||||||
|
return account_url("/")
|
||||||
0
cart/__init__.py
Normal file
0
cart/__init__.py
Normal file
2
cart/models/__init__.py
Normal file
2
cart/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from .order import Order, OrderItem
|
||||||
|
from .page_config import PageConfig
|
||||||
1
cart/models/order.py
Normal file
1
cart/models/order.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from shared.models.order import Order, OrderItem # noqa: F401
|
||||||
1
cart/models/page_config.py
Normal file
1
cart/models/page_config.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from shared.models.page_config import PageConfig # noqa: F401
|
||||||
0
events/__init__.py
Normal file
0
events/__init__.py
Normal file
4
events/models/__init__.py
Normal file
4
events/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from .calendars import (
|
||||||
|
Calendar, CalendarEntry, CalendarSlot,
|
||||||
|
TicketType, Ticket, CalendarEntryPost,
|
||||||
|
)
|
||||||
4
events/models/calendars.py
Normal file
4
events/models/calendars.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from shared.models.calendars import ( # noqa: F401
|
||||||
|
Calendar, CalendarEntry, CalendarSlot,
|
||||||
|
TicketType, Ticket, CalendarEntryPost,
|
||||||
|
)
|
||||||
0
market/__init__.py
Normal file
0
market/__init__.py
Normal file
8
market/models/__init__.py
Normal file
8
market/models/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from .market import (
|
||||||
|
Product, ProductLike, ProductImage, ProductSection,
|
||||||
|
NavTop, NavSub, Listing, ListingItem,
|
||||||
|
LinkError, LinkExternal, SubcategoryRedirect, ProductLog,
|
||||||
|
ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen,
|
||||||
|
CartItem,
|
||||||
|
)
|
||||||
|
from .market_place import MarketPlace
|
||||||
7
market/models/market.py
Normal file
7
market/models/market.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from shared.models.market import ( # noqa: F401
|
||||||
|
Product, ProductLike, ProductImage, ProductSection,
|
||||||
|
NavTop, NavSub, Listing, ListingItem,
|
||||||
|
LinkError, LinkExternal, SubcategoryRedirect, ProductLog,
|
||||||
|
ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen,
|
||||||
|
CartItem,
|
||||||
|
)
|
||||||
1
market/models/market_place.py
Normal file
1
market/models/market_place.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from shared.models.market_place import MarketPlace # noqa: F401
|
||||||
2
shared
2
shared
Submodule shared updated: 60cd08adc9...dfc41ada7d
33
templates/_email/magic_link.html
Normal file
33
templates/_email/magic_link.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="utf-8"></head>
|
||||||
|
<body style="margin:0;padding:0;background:#f5f5f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f4;padding:40px 0;">
|
||||||
|
<tr><td align="center">
|
||||||
|
<table width="480" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;border:1px solid #e7e5e4;padding:40px;">
|
||||||
|
<tr><td>
|
||||||
|
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:#1c1917;">{{ site_name }}</h1>
|
||||||
|
<p style="margin:0 0 24px;font-size:15px;color:#57534e;">Sign in to your account</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:15px;line-height:1.5;color:#44403c;">
|
||||||
|
Click the button below to sign in. This link will expire in 15 minutes.
|
||||||
|
</p>
|
||||||
|
<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;"><tr><td style="border-radius:8px;background:#1c1917;">
|
||||||
|
<a href="{{ link_url }}" target="_blank"
|
||||||
|
style="display:inline-block;padding:12px 32px;font-size:15px;font-weight:500;color:#ffffff;text-decoration:none;border-radius:8px;">
|
||||||
|
Sign in
|
||||||
|
</a>
|
||||||
|
</td></tr></table>
|
||||||
|
<p style="margin:0 0 8px;font-size:13px;color:#78716c;">Or copy and paste this link into your browser:</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:13px;word-break:break-all;">
|
||||||
|
<a href="{{ link_url }}" style="color:#1c1917;">{{ link_url }}</a>
|
||||||
|
</p>
|
||||||
|
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0;">
|
||||||
|
<p style="margin:0;font-size:12px;color:#a8a29e;">
|
||||||
|
If you did not request this email, you can safely ignore it.
|
||||||
|
</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
8
templates/_email/magic_link.txt
Normal file
8
templates/_email/magic_link.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Hello,
|
||||||
|
|
||||||
|
Click this link to sign in:
|
||||||
|
{{ link_url }}
|
||||||
|
|
||||||
|
This link will expire in 15 minutes.
|
||||||
|
|
||||||
|
If you did not request this, you can ignore this email.
|
||||||
19
templates/auth/check_email.html
Normal file
19
templates/auth/check_email.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{% extends "_types/root/_index.html" %}
|
||||||
|
{% block meta %}{% endblock %}
|
||||||
|
{% block title %}Check your email — Rose Ash{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="py-8 max-w-md mx-auto text-center">
|
||||||
|
<h1 class="text-2xl font-bold mb-4">Check your email</h1>
|
||||||
|
<p class="text-stone-600 mb-2">
|
||||||
|
We sent a sign-in link to <strong>{{ email }}</strong>.
|
||||||
|
</p>
|
||||||
|
<p class="text-stone-500 text-sm">
|
||||||
|
Click the link in the email to sign in. The link expires in 15 minutes.
|
||||||
|
</p>
|
||||||
|
{% if email_error %}
|
||||||
|
<div class="bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4">
|
||||||
|
{{ email_error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
36
templates/auth/login.html
Normal file
36
templates/auth/login.html
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{% extends "_types/root/_index.html" %}
|
||||||
|
{% block meta %}{% endblock %}
|
||||||
|
{% block title %}Login — Rose Ash{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="py-8 max-w-md mx-auto">
|
||||||
|
<h1 class="text-2xl font-bold mb-6">Sign in</h1>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('auth.start_login') }}" class="space-y-4">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium mb-1">Email address</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
id="email"
|
||||||
|
value="{{ email | default('') }}"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
class="w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
|
||||||
|
>
|
||||||
|
Send magic link
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user