Compare commits

...

7 Commits

Author SHA1 Message Date
phuoc f6d16b00db remove opencode files 2026-10-13 11:01:34 +02:00
phuoc 705ba49f12 added user creation and delete 2026-10-09 19:21:00 +02:00
phuoc e81a317b4b push local database to repo 2026-10-07 18:01:05 +02:00
phuoc ea7f12e8a3 store only the name in creator and editor 2026-10-07 17:52:44 +02:00
phuoc 94c663b9aa last call 2026-10-06 18:51:41 +02:00
phuoc 187fe49c3c modified google-oauth-migration.md 2026-10-06 18:44:22 +02:00
phuoc 4d80af2925 fix oauth tests to match implemented routes 2026-10-06 18:17:08 +02:00
9 changed files with 112 additions and 96 deletions
+11 -3
View File
@@ -716,9 +716,17 @@ Suggested messages, matching the repo's terse existing style:
- `docs: update AGENTS.md for the Google OAuth migration` - `docs: update AGENTS.md for the Google OAuth migration`
**Restore path for `ctp_blogr.sqlite`** (established in todo 2, required before todo 6): **Restore path for `ctp_blogr.sqlite`** (established in todo 2, required before todo 6):
`<backup path from todo 2>` restores the pre-migration database, and
`git checkout -- ctp_blogr.sqlite` restores the committed version. The database diff is a real - Branch: `feat/google-oauth`, created from `master` at `3dcb88e`. No commits, no pushes.
tracked change and must be inspected before any commit. - Verified backup (outside the working tree):
`/var/folders/cm/x5cx1bfd749gxd1x_zt6bvp40000gn/T/opencode/ctp_blogr-backup-20261005-202240.sqlite`
— `PRAGMA integrity_check` returns `ok`.
- Restore the pre-migration database:
`cp /var/folders/cm/x5cx1bfd749gxd1x_zt6bvp40000gn/T/opencode/ctp_blogr-backup-20261005-202240.sqlite ctp_blogr.sqlite`
- Restore the committed database: `git checkout -- ctp_blogr.sqlite`
- Abort the whole migration: `git checkout master`
The database diff is a real tracked change and must be inspected before any commit.
## Success criteria ## Success criteria
- Signing in with `phuoc@caotek.fr` yields a session whose `authenticated_userid` is that email - Signing in with `phuoc@caotek.fr` yields a session whose `authenticated_userid` is that email
BIN
View File
Binary file not shown.
+2 -34
View File
@@ -1,6 +1,6 @@
from wtforms import Form, StringField, TextAreaField, SelectField, DecimalField from wtforms import Form, StringField, TextAreaField, SelectField, DecimalField
from wtforms import IntegerField from wtforms import IntegerField
from wtforms.validators import InputRequired, Length from wtforms.validators import InputRequired, Length, Email
from wtforms.widgets import HiddenInput from wtforms.widgets import HiddenInput
strip_filter = lambda x: x.strip() if x else None strip_filter = lambda x: x.strip() if x else None
@@ -26,39 +26,7 @@ class TagForm(Form):
class UserEditForm(Form): class UserEditForm(Form):
id = IntegerField(widget=HiddenInput()) name = StringField('Email', validators=[Email(), InputRequired(), Length(min=1, max=255)],
name = StringField('Nom', validators=[InputRequired(), Length(min=1, max=255)],
filters=[strip_filter]) filters=[strip_filter])
class HistoForm(Form):
no_id = IntegerField(widget=HiddenInput())
mvt_cash = DecimalField('Montant à ajouter au portefeuille', places=2, validators=[InputRequired()])
class AllocationForm(Form):
no_cat = IntegerField(widget=HiddenInput())
classe = StringField("Classe d'actif", validators=[InputRequired(), Length(min=1, max=25)], filters=[strip_filter])
pc_cible = IntegerField(validators=[InputRequired()])
type = SelectField('Type', choices=[('ACTION','ACTION'),('AUTRE','AUTRE')])
ordre = IntegerField(validators=[InputRequired()])
bg_color = SelectField('Couleur de fond', choices=[('info','BLEU'),('danger','ROUGE'),('warning','ORANGE'),('success','VERT')])
class ActifForm(Form):
no_id = IntegerField(widget=HiddenInput())
classe = SelectField('Classe')
symbole = StringField('Symbole', validators=[InputRequired(), Length(min=1, max=15)], filters=[strip_filter])
libelle = StringField('Nom', validators=[InputRequired(), Length(min=1, max=45)], filters=[strip_filter])
nombre = IntegerField(validators=[InputRequired()])
pru = DecimalField('PRU', places=3, validators=[InputRequired()])
ter = DecimalField('TER', places=2, validators=[InputRequired()])
pc_rdt = DecimalField('% rendement', places=2, validators=[InputRequired()])
website = StringField('Web site', validators=[Length(min=1, max=100)], filters=[strip_filter])
class Actif2Form(Form):
no_id = IntegerField(widget=HiddenInput())
classe = SelectField('Classe')
symbole = StringField('Symbole', validators=[InputRequired(), Length(min=1, max=15)], filters=[strip_filter])
libelle = StringField('Libellé', validators=[InputRequired(), Length(min=1, max=45)], filters=[strip_filter])
pru = DecimalField('Total investi', places=3, validators=[InputRequired()])
cours = DecimalField('Total valeur', places=3, validators=[InputRequired()])
pc_rdt = DecimalField('% rendement', places=2, validators=[InputRequired()])
+18 -16
View File
@@ -8,10 +8,6 @@
</div> </div>
{% endif %} {% endif %}
<p>
<a class="btn btn-default" href="{{ url_retour }}"><span class="glyphicon glyphicon-chevron-left"></span> Retour</a>
</p>
<form action="{{ url }}" method="post" class="form"> <form action="{{ url }}" method="post" class="form">
{% for error in form.name.errors %} {% for error in form.name.errors %}
@@ -19,24 +15,30 @@
{% endfor %} {% endfor %}
<div class="form-group"> <div class="form-group">
<label class="required-field" for="name">{{form.name.label}}</label> <label class="required-field" for="name">{{form.name.label}}</label>
<input class="form-control" name="name" readonly type="text" value="{{form.name.data}}"> {% if name == "0" %}
<input class="form-control" name="name" type="text" value="{{form.name.data}}">
{% else %}
<input class="form-control" name="name" readonly type="text" value="{{form.name.data}}">
{% endif %}
</div> </div>
<p class="help-block">
L'adresse {{ form.name.data }} est votre compte Google autorisé. Elle
n'est pas modifiable ici : supprimez-la de la base et ajoutez-la à
<code>auth.admin_emails</code> ou à la table <code>users</code> pour
changer d'identité.
</p>
<br> <br>
<div class="form-group"> <div class="form-group">
<button class="btn btn-primary" type="submit" name="form.submitted"> <a class="btn btn-default" href="{{ url_retour }}"><span class="glyphicon glyphicon-chevron-left"></span> Retour</a>
<span class="glyphicon glyphicon-ok"></span> Enregistrer</button> {% if name == "0" %}
{% if 'group:administrators' in request.effective_principals %} <button class="btn btn-primary" type="submit" name="form.submitted">
<span class="glyphicon glyphicon-ok"></span> Enregistrer</button>
{% else %}
<button class="btn btn-warning" type="submit" name="form.deleted"> <button class="btn btn-warning" type="submit" name="form.deleted">
<span class="glyphicon glyphicon-remove"></span> Supprimer</button> <span class="glyphicon glyphicon-remove"></span> Supprimer</button>
<br>
<br>
<p class="text-danger">
Attention : la suppression est définitive. Pas de retour possible.
</p>
{% endif %} {% endif %}
</div> </div>
+12 -8
View File
@@ -2,26 +2,30 @@
{% block content %} {% block content %}
<p> <p>
<a href="{{ request.route_url('home' ) }}" class="btn btn-default" role="button"> <a href="{{ request.route_url('home' ) }}">[ Retour ]</a>&nbsp;
<span class="glyphicon glyphicon-chevron-left"></span> Retour</a> <a href="{{ request.route_url('user_edit', name='0') }}">[ Nouvel utilisateur ]</a>
</p> </p>
<table id="users_list" class="table table-striped table-bordered table-condensed"> <table id="users_list" class="table table-striped table-bordered table-condensed">
<thead> <thead>
<tr> <tr>
<th>No Id</th> <th>Email</th>
<th>Nom</th> <th>Google sub</th>
<th>Dernière connexion</th> <th>Dernière connexion</th>
</tr> </tr>
</thead> </thead>
{% for entry in users %} {% for entry in users %}
<tr> <tr>
<td>{{ entry.id }}</td>
<td> <td>
<a href="{{ request.route_url('user_edit', name=entry.name) }}"> {% if entry.name == 'phuoc@caotek.fr' %}
{{ entry.name }} {{ entry.name }} (admin)
</a> {% else%}
<a href="{{ request.route_url('user_edit', name=entry.name) }}">
{{ entry.name }}
</a>
{% endif %}
</td> </td>
<td>{{ entry.google_sub }}</td>
<td>{% if entry.last_logged %}{{ entry.last_logged.strftime("%d-%m-%Y - %H:%M") }}{% else %}jamais{% endif %}</td> <td>{% if entry.last_logged %}{{ entry.last_logged.strftime("%d-%m-%Y - %H:%M") }}{% else %}jamais{% endif %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
+22 -11
View File
@@ -277,7 +277,7 @@ def blog():
def url_callback(blog, code='code-de-test', state=None): def url_callback(blog, code='code-de-test', state=None):
if state is None: if state is None:
state = blog.google.state state = blog.google.state
return '/login/google/callback?code=%s&state=%s' % (code, quote(state)) return '/login_google_callback?code=%s&state=%s' % (code, quote(state))
def cookie_session(client): def cookie_session(client):
@@ -288,9 +288,12 @@ def cookie_session(client):
def demarrer_connexion(blog, client): def demarrer_connexion(blog, client):
# Premier saut : la page de connexion Google et la mise en session de # Premier saut : envoi du formulaire de connexion (POST /login avec
# form.submitted), redirection vers Google et mise en session de
# state / nonce / code_verifier. # state / nonce / code_verifier.
res = client.get('/login/google', status=302) res = client.post('/login',
{'form.submitted': 'Se connecter avec Google'},
status=302)
assert 'accounts.google.com' in res.location assert 'accounts.google.com' in res.location
assert blog.google.authorize_calls == 1 assert blog.google.authorize_calls == 1
assert blog.google.nonce and blog.google.code_verifier assert blog.google.nonce and blog.google.code_verifier
@@ -317,7 +320,10 @@ def test_page_de_connexion_ne_propose_qu_un_bouton_google(blog):
res = blog.client().get('/login') res = blog.client().get('/login')
assert res.status_int == 200 assert res.status_int == 200
assert 'Se connecter avec Google' in res.text assert 'Se connecter avec Google' in res.text
assert '/login/google' in res.text # Un seul formulaire : un POST vers /login qui porte form.submitted,
# pas de lien vers une route de depart dediee (elle n'existe pas).
assert '<form action="http://localhost/login" method="post">' in res.text
assert 'name="form.submitted"' in res.text
assert '<input type="password"' not in res.text assert '<input type="password"' not in res.text
assert 'name="password"' not in res.text assert 'name="password"' not in res.text
@@ -405,7 +411,7 @@ def test_compte_google_inconnu_refuse_sans_creation_de_fiche(blog):
def test_state_absent_refuse_la_connexion_sans_echange(blog): def test_state_absent_refuse_la_connexion_sans_echange(blog):
client = blog.client() client = blog.client()
res = client.get('/login/google/callback?code=code-de-test', status=302) res = client.get('/login_google_callback?code=code-de-test', status=302)
assert res.location == 'http://localhost/login' assert res.location == 'http://localhost/login'
assert blog.google.fetch_calls == 0 assert blog.google.fetch_calls == 0
@@ -434,7 +440,7 @@ def test_state_rejoue_refuse_la_connexion_sans_echange(blog):
# Premier passage : le client Google refuse, l'etat est consomme. # Premier passage : le client Google refuse, l'etat est consomme.
refuse = client.get( refuse = client.get(
'/login/google/callback?error=access_denied&state=%s' '/login_google_callback?error=access_denied&state=%s'
% quote(blog.google.state), status=302) % quote(blog.google.state), status=302)
assert refuse.location == 'http://localhost/login' assert refuse.location == 'http://localhost/login'
assert blog.google.fetch_calls == 0 assert blog.google.fetch_calls == 0
@@ -487,7 +493,7 @@ def test_code_absent_refuse_la_connexion_sans_echange(blog):
demarrer_connexion(blog, client) demarrer_connexion(blog, client)
res = client.get( res = client.get(
'/login/google/callback?state=%s' % quote(blog.google.state), status=302) '/login_google_callback?state=%s' % quote(blog.google.state), status=302)
assert res.location == 'http://localhost/login' assert res.location == 'http://localhost/login'
assert blog.google.fetch_calls == 0 assert blog.google.fetch_calls == 0
@@ -544,7 +550,7 @@ def test_refus_de_l_utilisateur_chez_google_affiche_un_message(blog):
demarrer_connexion(blog, client) demarrer_connexion(blog, client)
res = client.get( res = client.get(
'/login/google/callback?error=access_denied&state=%s' '/login_google_callback?error=access_denied&state=%s'
% quote(blog.google.state), status=302) % quote(blog.google.state), status=302)
assert res.location == 'http://localhost/login' assert res.location == 'http://localhost/login'
assert blog.google.fetch_calls == 0 assert blog.google.fetch_calls == 0
@@ -770,7 +776,9 @@ def test_session_expiree_est_refusee_avec_une_horloge_simulee(blog, monkeypatch)
def test_cookie_de_session_httponly(blog): def test_cookie_de_session_httponly(blog):
client = blog.client() client = blog.client()
res = client.get('/login/google', status=302) res = client.post('/login',
{'form.submitted': 'Se connecter avec Google'},
status=302)
entetes = res.headers.getall('Set-Cookie') entetes = res.headers.getall('Set-Cookie')
assert entetes assert entetes
assert any('session=' in entete and 'HttpOnly' in entete for entete in entetes) assert any('session=' in entete and 'HttpOnly' in entete for entete in entetes)
@@ -795,7 +803,8 @@ def test_le_garde_fou_reseau_bloque_les_sockets():
def test_le_client_google_est_pose_comme_attribut_du_registry(): def test_le_client_google_est_pose_comme_attribut_du_registry():
# services/google_oauth.py::includeme ecrit l'attribut, pas la cle : # services/google_oauth.py::includeme ecrit l'attribut, pas la cle :
# request.registry.google_oauth leveait AttributeError sinon, et la page # request.registry.google_oauth leveait AttributeError sinon, et la page
# /login rendait correctement alors que /login/google etait morte. # /login se rendait bien en GET alors que son envoi (POST, qui lance le
# flux vers Google) plantait.
wsgi_app, _ = configurer() wsgi_app, _ = configurer()
assert type(wsgi_app.registry.google_oauth).__name__ == 'GoogleOAuth' assert type(wsgi_app.registry.google_oauth).__name__ == 'GoogleOAuth'
@@ -803,7 +812,9 @@ def test_le_client_google_est_pose_comme_attribut_du_registry():
def test_la_redirection_vers_google_est_atteignable(): def test_la_redirection_vers_google_est_atteignable():
blog = demarrer() blog = demarrer()
client = blog.client() client = blog.client()
res = client.get('/login/google') res = client.post('/login',
{'form.submitted': 'Se connecter avec Google'},
status=302)
assert res.status_int == 302 assert res.status_int == 302
assert res.location.startswith('https://accounts.google.com/') assert res.location.startswith('https://accounts.google.com/')
assert 'code_challenge_method=S256' in res.location assert 'code_challenge_method=S256' in res.location
+2 -2
View File
@@ -61,7 +61,7 @@ def blog_edit(request):
form.populate_obj(entry) form.populate_obj(entry)
# interdire le car '/' dans le titre à cause du slug # interdire le car '/' dans le titre à cause du slug
entry.title = entry.title.replace('/','.') entry.title = entry.title.replace('/','.')
entry.creator = request.authenticated_userid entry.creator = request.authenticated_userid.split('@')[0]
entry.editor = entry.creator entry.editor = entry.creator
request.dbsession.add(entry) request.dbsession.add(entry)
@@ -72,7 +72,7 @@ def blog_edit(request):
# interdire le car '/' dans le titre à cause du slug # interdire le car '/' dans le titre à cause du slug
entry.title = entry.title.replace('/','.') entry.title = entry.title.replace('/','.')
entry.edited = datetime.datetime.now() entry.edited = datetime.datetime.now()
entry.editor = request.authenticated_userid entry.editor = request.authenticated_userid.split('@')[0]
return HTTPFound(location=request.route_url('blog', id=entry.id, slug=entry.slug)) return HTTPFound(location=request.route_url('blog', id=entry.id, slug=entry.slug))
if 'form.update_created' in request.params and form.validate(): if 'form.update_created' in request.params and form.validate():
+44 -22
View File
@@ -5,6 +5,7 @@ from pyramid.view import view_config
from pyramid.httpexceptions import HTTPForbidden, HTTPFound from pyramid.httpexceptions import HTTPForbidden, HTTPFound
from pyramid.security import remember, forget from pyramid.security import remember, forget
from ..services.google_oauth import GoogleOAuthError from ..services.google_oauth import GoogleOAuthError
from ..models.user import User
from ..services.user import UserService from ..services.user import UserService
from ..services.blog_record import BlogRecordService from ..services.blog_record import BlogRecordService
from ..forms import UserEditForm from ..forms import UserEditForm
@@ -16,12 +17,19 @@ def home(request):
last_ten = BlogRecordService.get_last_created(request) last_ten = BlogRecordService.get_last_created(request)
# get Chappatte cartoon # get Chappatte cartoon
entry = BlogRecordService.by_id(request, 95) entry = BlogRecordService.by_id(request, 95)
if entry:
cartoon_title = entry.title
cartoon_url = entry.body
else:
cartoon_title = "Cartoon non trouvé"
cartoon_url = ""
return { return {
'page_title': "This is the Way", 'page_title': "This is the Way",
'last_ten': last_ten, 'last_ten': last_ten,
'cartoon_title': entry.title, 'cartoon_title': cartoon_title,
'cartoon_url': entry.body, 'cartoon_url': cartoon_url,
} }
@@ -192,36 +200,50 @@ def users(request):
renderer='ctp_blogr:templates/user_edit.jinja2', renderer='ctp_blogr:templates/user_edit.jinja2',
permission='manage') permission='manage')
def user_edit(request): def user_edit(request):
# 'manage' et non 'view' : avec 'view', n'importe quel utilisateur
# connecte pouvait ouvrir /user_edit/<un-autre-email> et le modifier,
# ou /user_edit/0 pour creer une fiche.
message = '' message = ''
name = request.matchdict['name'] name = request.matchdict['name']
url = request.route_url('user_edit', name=name) url = request.route_url('user_edit', name=name)
if 'group:administrators' in request.effective_principals: url_retour = request.route_url('users')
url_retour = request.route_url('users')
if name == '0':
# nouvel utilisateur
user = User()
form = UserEditForm(request.POST, user)
page_title = "Nouvel utilisateur"
else: else:
url_retour = request.route_url('home') # lire la fiche du user
user = UserService.by_name(request, name)
if not user:
request.session.flash("Utilisateur non trouvé : %s" % name, 'danger')
return HTTPFound(location=url_retour)
user = UserService.by_name(request, name) form = UserEditForm(request.POST, user)
if not user: page_title = "Modification utilisateur"
# '0' n'existe plus : la création d'un utilisateur a disparu avec
# le mot de passe.
request.session.flash("Utilisateur non trouvé : %s" % name, 'danger')
return HTTPFound(location=url_retour)
form = UserEditForm(request.POST, user)
page_title = "Modification utilisateur"
if 'form.submitted' in request.params and form.validate(): if 'form.submitted' in request.params and form.validate():
del form.name # SECURITY: prevent overwriting of primary key # controle que name est bien un email
form.populate_obj(user) if name == '0':
request.session.flash("La fiche a été modifiée avec succès.", 'success') # It is obviously a user creation.
# check if the new user already exists in DB
new_user = UserService.by_name(request, form.name.data)
if new_user:
message = "Utilisateur déjà créé : %s" % form.name.data
else:
form.populate_obj(user)
# créer le nouveau
request.dbsession.add(user)
request.session.flash("La fiche a été créée avec succès.", 'success')
return HTTPFound(location=url_retour) return HTTPFound(location=url_retour)
if 'form.deleted' in request.params: if 'form.deleted' in request.params:
UserService.delete(request, user.id) # check whether name is an admin
request.session.flash("La fiche a été supprimée avec succès.", 'success') if name in 'group:administrators' in request.effective_principals:
request.session.flash("Il est interdit de supprimer un utisateur 'Admin'.", 'success')
else:
UserService.delete(request, user.id)
request.session.flash("La fiche a été supprimée avec succès.", 'success')
return HTTPFound(location=url_retour) return HTTPFound(location=url_retour)
return { return {
+1
View File
@@ -20,6 +20,7 @@ requires = [
'transaction', 'transaction',
'zope.sqlalchemy', 'zope.sqlalchemy',
'wtforms', # form library 2.2.1 'wtforms', # form library 2.2.1
'email_validator',
'webhelpers2', # various web building related helpers 2.0 'webhelpers2', # various web building related helpers 2.0
'markdown', 'markdown',
'authlib', # client OAuth 2.0 / OIDC pour l'authentification Google 'authlib', # client OAuth 2.0 / OIDC pour l'authentification Google