diff --git a/AGENTS.md b/AGENTS.md index ba324b4..6619d13 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,11 @@ # AGENTS.md Single-package Pyramid 2.0 blog app (`ctp_blogr`). Python 3.14, SQLite, Jinja2, WTForms, -SQLAlchemy 2.1, `pyramid_tm` transactions. UI strings, comments, and flash messages are -**French**; match that when editing views/templates. +SQLAlchemy 2.1, `pyramid_tm` transactions. Sign-in is Google OAuth only: Authlib +protocol/crypto primitives plus `joserfc` and `urllib.request` for HTTP. There are no +passwords anywhere — no password column, no password form fields, no local credential +path. UI strings, comments, and flash messages are **French**; match that when editing +views/templates. ## Environment @@ -18,14 +21,19 @@ Don't invent one; don't claim you ran one. ## Commands ```bash -python -m pytest # 2 tests, all in ctp_blogr/tests.py +ENV_GOOGLE_CLIENT_ID=... ENV_GOOGLE_CLIENT_SECRET=... pserve development.ini # waitress on localhost:9280 + debugtoolbar +ENV_GOOGLE_CLIENT_ID=... ENV_GOOGLE_CLIENT_SECRET=... pserve production.ini # binds *:9280, url_scheme https +ENV_GOOGLE_CLIENT_ID=... ENV_GOOGLE_CLIENT_SECRET=... pshell development.ini # preloaded: request, tm, dbsession, models +python -m pytest # 34 tests, all offline (2 in tests.py + 32 in test_oauth.py) python -m pytest ctp_blogr/tests.py::TestMyViewSuccessCondition # single test/class -pserve development.ini # waitress on localhost:9280 + debugtoolbar -pserve production.ini # binds *:9280, url_scheme https -pshell development.ini # preloaded: request, tm, dbsession, models -python -c "from pyramid.paster import get_app; get_app('development.ini')" # boot check +ENV_GOOGLE_CLIENT_ID=... ENV_GOOGLE_CLIENT_SECRET=... python -c "from pyramid.paster import get_app; get_app('development.ini')" # boot check ``` +- `pserve`, `pshell`, and the boot check all refuse to start without both env vars: + `__init__.py` raises `ConfigurationError` with a French message naming the missing ones. + PasteDeploy `%(ENV_...)s` interpolation does **not** work (`plaster_pastedeploy` 1.0.1 + ships no `os.environ` support), so the app reads `os.environ` itself; an `.ini` value, + if set, wins over the environment. - `pytest.ini` sets `testpaths = ctp_blogr` and `python_files = test*.py`, so a new test file must be named `test*.py` inside the package to be collected. - **`coverage` is broken**: `.coveragerc` still says `source = pyramid_blogr` (old package @@ -37,14 +45,67 @@ python -c "from pyramid.paster import get_app; get_app('development.ini')" # b ## Architecture / wiring -- `ctp_blogr/__init__.py::main` is the PasteDeploy app factory. It hardcodes the session - secret and auth-ticket secret inline — rotating them logs everyone out. -- Auth: `AuthTktAuthenticationPolicy` + `ACLAuthorizationPolicy` with +- `ctp_blogr/__init__.py::main` is the PasteDeploy app factory. Auth is Pyramid's + `SessionAuthenticationPolicy` + `ACLAuthorizationPolicy` with `root_factory='ctp_blogr.security.RootFactory'`. The whole ACL is 3 lines in `security.py`: `view` → `Authenticated`, `manage` → `group:administrators`, then `DENY_ALL`. -- **Admin is not a DB flag.** `groupfinder` (`services/user.py`) hardcodes - `userid == 'admin'` → `['group:administrators']`. Any other user gets `[]`, i.e. logged in - but no manage rights. Only `/users` requires `manage`. +- The session factory is explicit: 85-char secret, `timeout=36000` (10 h; the + `SignedCookieSessionFactory` default is 1200 s / 20 min), `httponly=True` always, + `secure=True` only when `session.cookie_secure = true` (set in production.ini). +- Identity is the **email**, stored as `request.authenticated_userid` (set via `remember()` + in the OAuth callback). +- **Admin is not a DB flag.** `services/user.py::make_groupfinder(admin_emails)` returns the + auth callback built from the `auth.admin_emails` setting in the `.ini` files. It must + **not** query the DB: `pyramid_tm` resolves `request.authenticated_userid` inside + `manager.begin()`, so a DB query there would run per request inside the transaction and + any DB error would 500 every route including `/login`. Falsy userid → `None`, admin + email → `['group:administrators']`, anything else → `[]` (logged in, no manage rights). + Callers test `'group:administrators' in request.effective_principals` (views and + templates). `/users` and `/user_edit` require `manage`; `blog_edit`, `tag_edit`, and + `/tags` require only `view`. +- `services/google_oauth.py::GoogleOAuth` is registered as an **attribute**, + `config.registry.google_oauth` (read as `request.registry.google_oauth`). Pyramid's + `Registry` subclasses `dict` but has no `__getattr__`, so a `registry['google_oauth']` + dict-key assignment would be invisible to the views. It resolves + `authorization_endpoint` / `token_endpoint` / `jwks_uri` / `issuer` from the Google + discovery document, cached, plus a cached JWKS (`CACHE_TTL = 3600`, counted by + `jwks_fetches`). Authlib has no Pyramid integration, so the app owns `state` / PKCE / + `nonce` itself; `authorize_redirect` / `save_authorize_data` are not used. Authlib is + used only for protocol primitives (`generate_token`, `create_s256_code_challenge`) and + the 3 HTTP calls go through `urllib.request` — do **not** add `requests` + (`authlib.integrations.requests_client` hard-imports it). `id_token` is verified with + `joserfc`, not `authlib.jose`. Scope is `openid email` with no `hd` parameter (the + second account is a personal Gmail address, so an `hd` lock would reject it). +- Routes added: `login_google` = `/login/google`, + `login_google_callback` = `/login/google/callback`. +- The callback pops `state` / `nonce` / `code_verifier` from the session **before** the + token exchange, and sets `request.environ['retry.attempts'] = attempt + 1` to declare + itself the last attempt. Both are required: a `pyramid_retry` replay restarts from the + original request cookie (which still carries `oauth_state`), which would re-send an + already-consumed authorization code and fail `invalid_grant`. +- Sign-in is gated on the `users` table (allowlist). No row is ever auto-created. Unknown + email sets `request.response.status_int = 403` explicitly and renders + `templates/denied.jinja2`, without raising `HTTPForbidden`. New template + `denied.jinja2` renders the refusal in French. +- The callback validates three ID-token claims before touching the allowlist, and each + one is pinned by a test that fails if the guard is removed: + - a missing `code` param is refused **before** any network call (`fetch_calls == 0`); + - `email_verified` must be exactly `True`. The identity *is* the email, so accepting + an unverified address would let an unverified Google account impersonate an + allowlisted one. Google returns this claim; don't "simplify" the check away; + - `sub` must be non-empty. Without it the matched row would get `google_sub = ''`, + which both collides with the `UNIQUE` constraint and leaves the record rebindable + by any other account presenting the same address. +- `views/default.py::denied` is registered with `@view_config(context=HTTPForbidden, ...)` + and sets a real 403. Unauthorized requests now return **HTTP 403 with the denied + page** — assert 403 in tests, and don't assume 200 means "the page worked" when + smoke-testing protected routes (`/users`, `/tags`, `/blog_edit/{id}`, `/tag_edit/{id}`, + `/user_edit/{name}`). +- `user_edit` is `permission='manage'` (any logged-in user could previously open or create + fiches). The create-user branch (`name='0'`) is gone; unknown names flash + "Utilisateur non trouvé" and redirect. `UserEditForm` (renamed from `UserCreateForm`) + has only `id` + `name`, and the view deletes `form.name` (not `form.id`) before + `populate_obj`, since the email is the identity. - Views bind to routes via `@view_config(route_name=...)`; routes are declared centrally in `routes.py`. Adding a URL means editing both. - `models/__init__.py` includes `pyramid_tm` + `pyramid_retry` and reifies `request.dbsession`. @@ -52,8 +113,8 @@ python -c "from pyramid.paster import get_app; get_app('development.ini')" # b breaks commit/abort behaviour. - Write path: view → `request.dbsession.add(...)` → implicit commit by `pyramid_tm`. Do **not** call `commit()` yourself. -- Query layer lives in `services/` (`BlogRecordService`, `UserService`) — all classmethods - taking `request`. Keep DB access out of views. +- Query layer lives in `services/` (`BlogRecordService`, `UserService`, plus the Google + collaborator) — all classmethods taking `request`. Keep DB access out of views. - Read filtering is duplicated per-method: anonymous users only ever see `status == 'publié'` (`by_criteria`, `by_tag`, `get_last_created`). `by_id` does **not** filter — any post is readable by direct URL. Preserve that asymmetry or you change access behaviour. @@ -63,22 +124,31 @@ python -c "from pyramid.paster import get_app; get_app('development.ini')" # b (relative to the package), but `default.py::user_edit` uses the asset spec `'ctp_blogr:templates/user_edit.jinja2'`. Both work; don't "normalize" one without checking the jinja2 search path. +- `login.jinja2` has a single "Se connecter avec Google" control and no password markup; + `layout.jinja2` shows the Utilisateurs nav item only for admins; the password-change nav + item and the "Nouvel utilisateur" button are gone. `users.jinja2` is NULL-safe for + `last_logged` (renders "jamais"); `user_edit.jinja2` shows the email readonly. - `forms.py` contains dead forms from another app (`HistoForm`, `AllocationForm`, `ActifForm`, `Actif2Form`) matching orphaned DB tables `histo`, `allocation`, `actifs`. Ignore them. ## Database -- **`ctp_blogr.sqlite` is committed and is the real dev database** (~99 posts, 8 tags, users - `admin` and `phuoc`). Both `development.ini` and `production.ini` point at it via +- **`ctp_blogr.sqlite` is committed and is the real dev database** (~99 posts, 8 tags; users + are now `phuoc@caotek.fr` (admin, via the `.ini` setting) and `ctphuoc@gmail.com` + (authenticated, no manage)). Both `development.ini` and `production.ini` point at it via `%(here)s`. `.gitignore` lists `*.sqlite`, but the file is already tracked, so the ignore rule does nothing — every write view's `pyramid_tm` commit is a real diff to review. +- `users`: `password` dropped, `google_sub` added (nullable, UNIQUE), `name` still NOT NULL + + UNIQUE and now stores the email. `entries.creator` / `entries.editor` are `Unicode(255)`. - The DB contains an `alembic_version` table (rev `b8f8216d72c7`) but there is **no `alembic.ini` and no `migrations/` directory in this repo**. Alembic is installed in the - venv, yet nothing here is wired to it. So the schema cannot be regenerated. + venv, yet nothing here is wired to it. The OAuth schema changes were hand-applied SQL, + so the schema cannot be regenerated. - **The DB file is the source of truth for schema.** Changing a model means: apply the equivalent `ALTER TABLE` to `ctp_blogr.sqlite` by hand, and tell the user migrations are missing rather than pretending `Base.metadata.create_all()` covers them. `create_all()` is - only used by the in-memory test fixture (`tests.py::BaseTest.init_database`). + only used by the in-memory test fixtures (`tests.py::BaseTest.init_database`, + `test_oauth.py::demarrer`). - `Tags` is defined in `models/blog_record.py` but is **not** imported in `models/__init__.py` (only `User` and `BlogRecord` are, before `configure_mappers()`). It still works only because importing `blog_record` defines the class as a side effect. Import it explicitly if you touch @@ -86,9 +156,6 @@ python -c "from pyramid.paster import get_app; get_app('development.ini')" # b ## Known-broken (verified, do not re-diagnose) -- `views/default.py:142` calls bare `delete(request, user.id)` — `delete` is neither imported - nor defined. Submitting the delete button on `/user_edit` raises `NameError`. The intended - call is `UserService.delete(request, user.id)`. - `views/default.py:18` — `home` hardcodes `BlogRecordService.by_id(request, 95)` and then reads `entry.title` / `entry.body` with no `None` check. The home page raises `AttributeError` on any database where id 95 doesn't exist (e.g. a freshly created one). `home.jinja2:18` hardcodes the @@ -98,20 +165,35 @@ python -c "from pyramid.paster import get_app; get_app('development.ini')" # b - `routes.py:5` — `'/blog/{id:\d+}/{slug}'` is a non-raw string, so Python 3.12+ emits `SyntaxWarning: "\d" is an invalid escape sequence`. Prefix with `r` if you edit it. - `models/meta.py` uses `sqlalchemy.ext.declarative.declarative_base` (`MovedIn20Warning`). -- **Unauthorized requests return HTTP 200, not 403** (verified with and without - `pyramid_debugtoolbar`). `forbidden_view_config` renders `login.jinja2` and the status stays - 200. Don't write tests asserting 403, and don't assume 200 means "the page worked" — check - for the login form when smoke-testing protected routes (`/users`, `/tags`, `/blog_edit/{id}`, - `/tag_edit/{id}`, `/user_edit/{name}`). +- Draft leak in `by_id` (no status filter) and the `LIKE` search leak in `by_criteria` + (anonymous search matches drafts/privates, only the display is filtered elsewhere) are + pre-existing and deliberately not fixed by the OAuth work. +- Any logged-in user (including the non-admin account) can edit and delete ANY post, + because `security.py` grants `view` to every authenticated user and `blog_edit` / + `tag_edit` only require `view`. Pre-existing, deliberately unchanged (README documents an + "admin ou redacteur" role). Pinned by + `test_DEFAUT_connu_un_utilisateur_non_administrateur_peut_modifier_un_billet`. +- `UserService.delete` has no FK cascade: deleting a user leaves their email stranded in + `entries.creator` / `entries.editor`. +- Pre-existing DB defect, not caused by the OAuth work: `PRAGMA foreign_key_check` aborts + with "foreign key mismatch" because `fk_allocation_classe_actifs` references + `actifs.classe`, which is neither UNIQUE nor a PK. +- No CSRF protection on the pre-existing POST views, and `logout` is a state-mutating GET. + Accepted risk, not fixed. Pyramid 2.1's cookie session exposes `new_csrf_token()` / + `get_csrf_token()` if someone wants to add it. +- Identity lives entirely in the signed client-side cookie (no server-side session + backend). Payload is ~250 bytes against Pyramid's hard 4064-byte limit, so the only + growth vector is unbounded flash accumulation. ## Testing -`ctp_blogr/tests.py` has only two tests and they are duplicates that both assert the same -success case (`test_failing_view` is a copy-paste of `test_passing_view`). There is no coverage -of auth, forms, or the DB. `BaseTest` sets up `sqlite:///:memory:` with -`Base.metadata.create_all()` and wraps the session in `transaction.manager`, aborting in -teardown. `WebTest` is installed and is the right tool for new view tests — see -`pyramid.testing.DummyRequest` usage there for the lighter alternative. +`ctp_blogr/tests.py` still has only its two original duplicate tests (both assert the same +success case; `test_failing_view` is a copy-paste of `test_passing_view`). New +`ctp_blogr/test_oauth.py` adds 32 tests on a real WSGI app over `sqlite:///:memory:` with +the Google collaborator faked and sockets blocked by an autouse fixture — 34 passing in +total, all fully offline. Tests named `DEFAUT_*` pin known defects on purpose (observed +behaviour, not desired). `BaseTest` wraps its session in `transaction.manager`, aborting in +teardown. `WebTest` is installed; `test_oauth.py` shows the pattern for new view tests. ## Conventions @@ -122,10 +204,14 @@ teardown. `WebTest` is installed and is the right tool for new view tests — se - Form submission is detected by string key presence: `if 'form.submitted' in request.params`, plus `'form.update_created'` and `'form.deleted'` for the extra buttons. Buttons must set `name="form.submitted"` etc. -- `del form.id` before `form.populate_obj(entry)` is a deliberate guard against overwriting the - primary key. Keep it. +- `del form.id` (blog/tag forms) / `del form.name` (`user_edit`) before `populate_obj` is a + deliberate guard against overwriting the primary key / identity. Keep it. - `/blog_bytag/{tag}/{retour}` — `retour` is a **route name** (`request.route_url(retour)`), not a URL. Callers pass e.g. `retour='home'`. -- One commit, currently on `master`. `main` and `origin/*` point at the same commit, so branch - choice is not settled — ask before creating branches or pushing to the self-hosted - `https://git.caotek.fr/phuoc/ctp_blogr.git`. Commit only when asked. \ No newline at end of file +- Currently on branch `feat/google-oauth`, uncommitted, nothing pushed (`master` tracks + `origin/master` at the same commit, so branch choice is still not settled). A verified + pre-migration DB backup exists outside the tree at + `/var/folders/cm/x5cx1bfd749gxd1x_zt6bvp40000gn/T/opencode/ctp_blogr-backup-20261005-202240.sqlite` + (`PRAGMA integrity_check` returns `ok`); restore with `cp` over `ctp_blogr.sqlite`. + Ask before creating branches, committing, or pushing to the self-hosted + `https://git.caotek.fr/phuoc/ctp_blogr.git`. Commit only when asked. diff --git a/CHANGES.txt b/CHANGES.txt index c9f9b0f..ee9bfa6 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -2,3 +2,4 @@ --- - Initial version. + diff --git a/README.md b/README.md index 450b740..2ebdbd4 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,13 @@ Bien que **ctp_blogr** soit une application minimale et simple, elle possède to ## Add-on requis ## -- Python 3.9.6 -- Pyramid 2.0.2 -- SQLite 3.43.2 -- pyramid-jinja2 2.7 : view templating -- wtforms 3.2.1 : form library -- webhelpers2 2.0 : various web building related helpers -- Markdown 3.4.1 : +- Python 3.14.8 +- Pyramid 2.1 +- SQLite 3.43.2 2023-10-10 +- pyramid-jinja2 2.10.1 : view templating +- WTForms 3.2.2 : form library +- WebHelpers2 2.1 : various web building related helpers +- Markdown 3.11 : ### 1.0 (22.01.2023) diff --git a/client_id.apps.googleusercontent.com.json b/client_id.apps.googleusercontent.com.json new file mode 100644 index 0000000..f8ef1ca --- /dev/null +++ b/client_id.apps.googleusercontent.com.json @@ -0,0 +1 @@ +{"web":{"client_id":"1001078541512-r65d0gladmatsh9bhnevhe24f6hv1ikt.apps.googleusercontent.com","project_id":"thinking-window-355605","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"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","redirect_uris":["https://phuoc.caotek.fr/login/google/callback"],"javascript_origins":["https://phuoc.caotek.fr","http://localhost:9280"]}} \ No newline at end of file diff --git a/ctp_blogr.sqlite b/ctp_blogr.sqlite index 8fa13a8..2c95bfa 100644 Binary files a/ctp_blogr.sqlite and b/ctp_blogr.sqlite differ diff --git a/ctp_blogr/__init__.py b/ctp_blogr/__init__.py index 20ebb3a..b63beb8 100644 --- a/ctp_blogr/__init__.py +++ b/ctp_blogr/__init__.py @@ -1,19 +1,77 @@ +import os + from pyramid.config import Configurator -from pyramid.authentication import AuthTktAuthenticationPolicy +from pyramid.authentication import SessionAuthenticationPolicy from pyramid.authorization import ACLAuthorizationPolicy from pyramid.session import SignedCookieSessionFactory -from .services.user import groupfinder +from .services.user import make_groupfinder + + +# Le cookie de session porte desormais l'identite : il est signe avec +# hashalg='sha512', donc le secret doit faire au moins 64 caracteres. +SESSION_SECRET = ( + 'M56kCo51EibaMo0cAo1HRNFGaRl90-Mg8i1T_FaI8ugo7pJW4imVWREQr8hlTs' + 'aY3DxyMl211WY0oCK-nIFBg' +) + +# Le defaut de SignedCookieSessionFactory est de 20 minutes, ce qui est trop +# court pour un ecran de consentement Google + une connexion + une 2FA. +# On garde les 10 heures (36000 s) de l'ancienne AuthTkt. +SESSION_TIMEOUT = 36000 + +class ConfigurationError(Exception): + """Erreur de configuration detectee au demarrage.""" + + +def read_google_settings(settings): + # Returnne un dict {google.client_id, google.client_secret}. + # Le reglage de l'ini, s'il existe, l'emporte sur l'environnement. + google = {} + # import pdb;pdb.set_trace() + key = 'google.client_id' + google[key] = settings.get(key) + key = 'google.client_secret' + google[key] = settings.get(key) + + if not google[key]: + raise ConfigurationError( + "Configuration Google incomplete : les variables d'environnement " + "sont absentes. Renseignez-les avant de demarrer l'application.") + + return google + + +def admin_emails(settings): + # Liste des emails qui portent le groupe 'group:administrators'. + # Configurable, donc immediate et sans requete SQL. + raw = settings.get('auth.admin_emails', '') + return [item.strip() for item in raw.replace(',', ' ').split() if item.strip()] def main(global_config, **settings): """ This function returns a Pyramid WSGI application. """ - # session factory - my_session_factory = SignedCookieSessionFactory('hZug2zPt7hT2MZ') + google = read_google_settings(settings) + settings['google.client_id'] = google['google.client_id'] + settings['google.client_secret'] = google['google.client_secret'] - authentication_policy = AuthTktAuthenticationPolicy('J2wv322aL5DTn2', - callback=groupfinder, hashalg='sha512', timeout=36000) + admins = admin_emails(settings) + callback = make_groupfinder(admins) + + # Le cookie porte l'identite : il doit etre protege contre le vol par XSS + # (httponly) et envoye en TLS seulement en production (secure). + secure = settings.get('session.cookie_secure', 'false').strip().lower() \ + in ('true', '1', 'yes', 'on') + + my_session_factory = SignedCookieSessionFactory( + SESSION_SECRET, + timeout=SESSION_TIMEOUT, + httponly=True, + secure=secure, + ) + + authentication_policy = SessionAuthenticationPolicy(callback=callback) authorization_policy = ACLAuthorizationPolicy() with Configurator(settings=settings, root_factory='ctp_blogr.security.RootFactory', @@ -22,6 +80,7 @@ def main(global_config, **settings): config.include('pyramid_jinja2') config.include('.models') config.include('.routes') + config.include('.services.google_oauth') config.set_session_factory(my_session_factory) config.scan() return config.make_wsgi_app() diff --git a/ctp_blogr/forms.py b/ctp_blogr/forms.py index 03d0480..c0100f5 100644 --- a/ctp_blogr/forms.py +++ b/ctp_blogr/forms.py @@ -1,6 +1,6 @@ from wtforms import Form, StringField, TextAreaField, SelectField, DecimalField -from wtforms import IntegerField, PasswordField -from wtforms.validators import InputRequired, Length, EqualTo +from wtforms import IntegerField +from wtforms.validators import InputRequired, Length from wtforms.widgets import HiddenInput strip_filter = lambda x: x.strip() if x else None @@ -25,12 +25,10 @@ class TagForm(Form): tag = StringField('Tag', validators=[InputRequired(), Length(min=1, max=25)], filters=[strip_filter]) -class UserCreateForm(Form): +class UserEditForm(Form): id = IntegerField(widget=HiddenInput()) name = StringField('Nom', validators=[InputRequired(), Length(min=1, max=255)], filters=[strip_filter]) - password = PasswordField('Mot de passe') - confirm = PasswordField('Confirmer', validators=[EqualTo('password', message='Les 2 Passwords doivent être identiques')]) class HistoForm(Form): no_id = IntegerField(widget=HiddenInput()) diff --git a/ctp_blogr/models/blog_record.py b/ctp_blogr/models/blog_record.py index 342a9f6..6f3b753 100644 --- a/ctp_blogr/models/blog_record.py +++ b/ctp_blogr/models/blog_record.py @@ -17,9 +17,9 @@ class BlogRecord(Base): title = Column(Unicode(255), unique=True, nullable=False) body = Column(UnicodeText, default='') created = Column(DateTime, default=datetime.datetime.now) - creator = Column(Unicode(50), default='') + creator = Column(Unicode(255), default='') edited = Column(DateTime, default=datetime.datetime.now) - editor = Column(Unicode(50), default='') + editor = Column(Unicode(255), default='') tag = Column(Unicode(25)) status = Column(Unicode(50), default='brouillon') diff --git a/ctp_blogr/models/user.py b/ctp_blogr/models/user.py index 9f053a2..ffb7a44 100644 --- a/ctp_blogr/models/user.py +++ b/ctp_blogr/models/user.py @@ -8,27 +8,14 @@ from sqlalchemy import ( DateTime, #<- time abstraction field ) -from passlib.apps import custom_app_context as blogger_pwd_context - class User(Base): __tablename__ = 'users' id = Column(Integer, primary_key=True) + # name contient l'email autorise (liste blanche) : c'est l'identite renvoyee + # par remember() et donc request.authenticated_userid. name = Column(Unicode(255), unique=True, nullable=False) - password = Column(Unicode(255), nullable=False) + # sub Google, identifiant stable de l'identite, renseigne a la premiere + # connexion puis utilise en priorite sur les suivantes. + google_sub = Column(Unicode(255), unique=True, nullable=True) last_logged = Column(DateTime, default=datetime.datetime.utcnow) - - def verify_password(self, password): - # is it cleartext? - if password == self.password: - self.set_password(password) - # verify password - result = blogger_pwd_context.verify(password, self.password) - if result: - # pwd OK, set last login date - self.last_logged = datetime.datetime.now() - return result - - def set_password(self, password): - password_hash = blogger_pwd_context.encrypt(password) - self.password = password_hash diff --git a/ctp_blogr/routes.py b/ctp_blogr/routes.py index 42557c1..d48e3ab 100644 --- a/ctp_blogr/routes.py +++ b/ctp_blogr/routes.py @@ -2,11 +2,12 @@ def includeme(config): config.add_static_view('static', 'static', cache_max_age=3600) config.add_route('home', '/') config.add_route('apropos', '/apropos') - config.add_route('blog', '/blog/{id:\d+}/{slug}') + config.add_route('blog', '/blog/{id}/{slug}') config.add_route('blog_bytag', '/blog_bytag/{tag}/{retour}') config.add_route('blog_edit', '/blog_edit/{id}') config.add_route('blog_search', '/blog_search') config.add_route('login', '/login') + config.add_route('login_google_callback', '/login_google_callback') config.add_route('logout', '/logout') config.add_route('tags', '/tags') config.add_route('tag_edit', '/tag_edit/{id}') diff --git a/ctp_blogr/services/google_oauth.py b/ctp_blogr/services/google_oauth.py new file mode 100644 index 0000000..7b88843 --- /dev/null +++ b/ctp_blogr/services/google_oauth.py @@ -0,0 +1,160 @@ +import json +import time +import urllib.error +import urllib.parse +import urllib.request + +from authlib.common.security import generate_token +from authlib.oauth2.rfc7636 import create_s256_code_challenge +from joserfc import jwt as joserfc_jwt +from joserfc.jwk import KeySet + + +DISCOVERY_URL = 'https://accounts.google.com/.well-known/openid-configuration' + +# 'profile' n'est pas demande : ni nom affiche ni avatar ne sont utilises. +SCOPE = 'openid email' + +# Duree de validite du cache du document de decouverte et des cles JWKS. +CACHE_TTL = 3600 + + +class GoogleOAuthError(Exception): + """Echec de l'echange avec Google.""" + + +def _http_get_json(url): + with urllib.request.urlopen(url, timeout=10) as response: + return json.loads(response.read().decode('utf-8')) + + +def _http_post_form(url, fields): + data = urllib.parse.urlencode(fields).encode('utf-8') + request = urllib.request.Request( + url, data=data, + headers={'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json'}, + ) + with urllib.request.urlopen(request, timeout=10) as response: + return json.loads(response.read().decode('utf-8')) + + +class GoogleOAuth(object): + # Client OIDC Google. Les points de terminaison (authorization_endpoint, + # token_endpoint, jwks_uri, issuer) ne sont jamais ecrits a la main : ils + # viennent du document de decouverte, mis en cache comme les cles. + # + # state, nonce et code_verifier sont produits par l'appelant et conserves en + # session : Authlib ne fournit pas d'integration Pyramid, donc ni + # authorize_redirect ni save_authorize_data ne sont utilisables ici. + + def __init__(self, client_id, client_secret, + discovery_url=DISCOVERY_URL, + scope=SCOPE, + cache_ttl=CACHE_TTL, + clock=time.time, + get_json=None, + post_form=None): + self.client_id = client_id + self.client_secret = client_secret + self.discovery_url = discovery_url + self.scope = scope + self.cache_ttl = cache_ttl + self._clock = clock + self._get_json = get_json or _http_get_json + self._post_form = post_form or _http_post_form + self._metadata = None + self._metadata_at = 0.0 + self._key_set = None + self._keys_at = 0.0 + self.jwks_fetches = 0 + + def metadata(self): + if self._metadata is None or self._clock() - self._metadata_at > self.cache_ttl: + try: + self._metadata = self._get_json(self.discovery_url) + except Exception as exc: + raise GoogleOAuthError( + "Impossible de lire le document de decouverte de Google : %s" % exc) + self._metadata_at = self._clock() + return self._metadata + + def key_set(self): + if self._key_set is None or self._clock() - self._keys_at > self.cache_ttl: + uri = self.metadata().get('jwks_uri') + if not uri: + raise GoogleOAuthError("Le document de decouverte ne contient pas jwks_uri.") + try: + self._key_set = KeySet.import_key_set(self._get_json(uri)) + except Exception as exc: + raise GoogleOAuthError("Impossible de lire les cles de Google : %s" % exc) + self._keys_at = self._clock() + self.jwks_fetches += 1 + return self._key_set + + def create_authorization_url(self, redirect_uri, state, nonce, code_verifier): + params = { + 'client_id': self.client_id, + 'response_type': 'code', + 'redirect_uri': redirect_uri, + 'scope': self.scope, + 'state': state, + 'nonce': nonce, + 'code_challenge': create_s256_code_challenge(code_verifier), + 'code_challenge_method': 'S256', + } + # Pas de 'hd' : caotest.fr est un domaine Workspace et ctphuoc@gmail.com + # est un compte Gmail personnel, donc hd=caotek.fr le verrouillerait. + endpoint = self.metadata()['authorization_endpoint'] + return '%s?%s' % (endpoint, urllib.parse.urlencode(params)) + + def fetch_claims(self, code, code_verifier, redirect_uri, nonce): + # Un seul appel au point de terminaison de jetons par connexion. + fields = { + 'code': code, + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'redirect_uri': redirect_uri, + 'grant_type': 'authorization_code', + 'code_verifier': code_verifier, + } + try: + token = self._post_form(self.metadata()['token_endpoint'], fields) + except GoogleOAuthError: + raise + except Exception as exc: + raise GoogleOAuthError("Google a refuse le code d'autorisation : %s" % exc) + + id_token = token.get('id_token') + if not id_token: + raise GoogleOAuthError("Google n'a pas renvoyé de id_token.") + + return self.verify_id_token(id_token, nonce) + + def verify_id_token(self, id_token, nonce): + metadata = self.metadata() + registry = joserfc_jwt.JWTClaimsRegistry( + iss={'essential': True, 'value': metadata.get('issuer')}, + aud={'essential': True, 'value': self.client_id}, + ) + try: + token = joserfc_jwt.decode( + id_token, self.key_set(), algorithms=['RS256'], registry=registry) + except Exception as exc: + raise GoogleOAuthError("Le id_token renvoye par Google est invalide : %s" % exc) + + claims = dict(token.claims) + if nonce is not None and claims.get('nonce') != nonce: + raise GoogleOAuthError("Le nonce renvoye par Google ne correspond pas.") + return claims + + +def includeme(config): + # Lu ensuite par request.registry.google_oauth. Pyramid stocke les services + # comme attribut : Registry herite de dict mais n'a pas de __getattr__, donc + # une ecriture registry['google_oauth'] serait invisible ici. + settings = config.get_settings() + config.registry.google_oauth = GoogleOAuth( + client_id=settings['google.client_id'], + client_secret=settings['google.client_secret'], + ) diff --git a/ctp_blogr/services/user.py b/ctp_blogr/services/user.py index 2d771f1..9b45515 100644 --- a/ctp_blogr/services/user.py +++ b/ctp_blogr/services/user.py @@ -14,19 +14,41 @@ class UserService(object): item = request.dbsession.query(User).filter(User.name == name).first() return item + @classmethod + def by_google_sub(cls, request, google_sub): + # Le sub est l'identifiant stable de Google : l'email peut changer. + if not google_sub: + return None + return request.dbsession.query(User).filter( + User.google_sub == google_sub).first() + @classmethod def delete(cls, request, id): + # Attention : aucune cascade vers entries.creator / entries.editor, + # supprimer un utilisateur laisse son email dans ces colonnes. request.dbsession.query(User).filter(User.id == id).delete(synchronize_session=False) - return + return -def groupfinder(userid, request): +def make_groupfinder(admin_emails): + # Retourne le callback de la politique d'authentification. + # + # AUCUNE requete SQL ici : pyramid_tm lit request.authenticated_userid dans + # manager.begin(), donc une requete par requete HTTP a l'interieur de la + # transaction, et la moindre erreur de base mettrait en 500 toutes les + # routes, y compris /login et le callback OAuth lui-meme. + # La liste des administrateurs vient de la configuration auth.admin_emails, + # et les comptes autorises sont ceux de la table users, verifiee dans le + # callback OAuth : un userid inconnu ne peut donc pas arriver ici. + admins = frozenset(admin_emails) - if userid: - # user name is 'admin' ? - if userid == 'admin': + def groupfinder(userid, request): + if not userid: + # None => non connecte + return None + if userid in admins: return ['group:administrators'] - else: - return [] # it means that userid is logged in - else: - # it returns None if userid isn't logged in - return None + # [] => connecte mais sans droit manage + return [] + + return groupfinder + diff --git a/ctp_blogr/templates/denied.jinja2 b/ctp_blogr/templates/denied.jinja2 new file mode 100644 index 0000000..c148161 --- /dev/null +++ b/ctp_blogr/templates/denied.jinja2 @@ -0,0 +1,26 @@ +{% extends "layout.jinja2" %} + +{% block content %} + +