Repository navigation
[16.0] pms_fastapi: turn on a second factor from the client - #159
Merged
Merged
Conversation
jesusVMayor
added this pull request to stack #158
September 25, 2026 16:45
Diff CoverageDiff: origin/16.0...HEAD, staged and unstaged changes
Summary
pms_fastapi/models/pms_mfa_setup.pyLines 1-8 ! 1 from odoo import api, fields, models
2
3
! 4 class PmsFastapiMfaSetup(models.Model):
5 """A second factor a user has been offered but has not confirmed yet.
6
7 The secret is kept here rather than handed to the client and taken back on
8 trust, so that what ends up protecting the account is what the serverLines 8-29 8 trust, so that what ends up protecting the account is what the server
9 offered and nothing else.
10 """
11
! 12 _name = "pms.fastapi.mfa.setup"
! 13 _description = "Second factor pending confirmation"
14
! 15 user_id = fields.Many2one(
16 "res.users", required=True, index=True, ondelete="cascade"
17 )
! 18 secret = fields.Char(required=True)
! 19 expire = fields.Datetime(required=True)
20
! 21 _sql_constraints = [
22 ("unique_user", "unique(user_id)", "Only one setup at a time per user!"),
23 ]
24
! 25 @api.autovacuum
! 26 def _remove_expired_setups(self):
! 27 records = self.search([("expire", "<", fields.Datetime.now())])
! 28 return records.unlink()pms_fastapi/routers/two_factor.pyLines 1-17 ! 1 import base64
! 2 import os
! 3 import re
! 4 from datetime import timedelta
! 5 from urllib.parse import quote, urlencode, urlparse
6
! 7 from fastapi import Response, status
! 8 from fastapi.responses import JSONResponse
9
! 10 from odoo import _, fields, models
! 11 from odoo.exceptions import AccessDenied
12
! 13 from odoo.addons.auth_totp.models.totp import (
14 ALGORITHM,
15 DIGITS,
16 TIMESTEP,
17 TOTP_SECRET_SIZE,Lines 15-34 15 DIGITS,
16 TIMESTEP,
17 TOTP_SECRET_SIZE,
18 )
! 19 from odoo.addons.pms_fastapi.dependencies import AuthenticatedEnv
! 20 from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router
! 21 from odoo.addons.pms_fastapi.schemas.two_factor import (
22 TwoFactorActivation,
23 TwoFactorSetup,
24 )
25
! 26 SETUP_MAX_AGE = 15 * 60
27
28
! 29 @pms_api_router.get("/user/two-factor", response_model=TwoFactorSetup, tags=["user"])
! 30 async def start_two_factor(env: AuthenticatedEnv):
31 """
32 Offers a second factor to the account in session.
33
34 The returned link is what an authentication app reads, usually from a QRLines 34-46 34 The returned link is what an authentication app reads, usually from a QR
35 code; the secret is the same thing in text, for typing it by hand. Neither
36 protects the account until it is confirmed.
37 """
! 38 return env["pms.fastapi.two.factor.endpoint"]._offer(env.user)
39
40
! 41 @pms_api_router.post("/user/two-factor", status_code=204, tags=["user"])
! 42 async def activate_two_factor(activation: TwoFactorActivation, env: AuthenticatedEnv):
43 """
44 Turns on the second factor that was offered.
45
46 The password is asked for again so that an unattended session cannotLines 45-83 45
46 The password is asked for again so that an unattended session cannot
47 change how the account is protected.
48 """
! 49 return env["pms.fastapi.two.factor.endpoint"]._activate(
50 env.user, activation.code, activation.password.get_secret_value()
51 )
52
53
! 54 class PmsFastapiTwoFactorEndpoint(models.AbstractModel):
! 55 _name = "pms.fastapi.two.factor.endpoint"
! 56 _description = "two factor setup helper"
57
! 58 def _offer(self, user):
! 59 if user.totp_enabled:
! 60 return self._already_protected_response()
! 61 secret = base64.b32encode(os.urandom(TOTP_SECRET_SIZE // 8)).decode()
! 62 self._store_offer(user, secret)
! 63 return TwoFactorSetup(uri=self._uri(user, secret), secret=secret)
64
! 65 def _store_offer(self, user, secret):
! 66 Setup = self.env["pms.fastapi.mfa.setup"].sudo()
! 67 values = {
68 "secret": secret,
69 "expire": fields.Datetime.now() + timedelta(seconds=SETUP_MAX_AGE),
70 }
! 71 pending = Setup.search([("user_id", "=", user.id)], limit=1)
! 72 if pending:
! 73 return pending.write(values)
! 74 return Setup.create(dict(values, user_id=user.id))
75
! 76 def _activate(self, user, code, password):
! 77 if user.totp_enabled:
! 78 return self._already_protected_response()
! 79 pending = (
80 self.env["pms.fastapi.mfa.setup"]
81 .sudo()
82 .search(
83 [("user_id", "=", user.id), ("expire", ">", fields.Datetime.now())],Lines 83-96 83 [("user_id", "=", user.id), ("expire", ">", fields.Datetime.now())],
84 limit=1,
85 )
86 )
! 87 if not pending:
! 88 return self._offer_required_response()
! 89 try:
! 90 user._check_credentials(password, {"interactive": True})
! 91 except AccessDenied:
! 92 return self._problem_response(
93 status.HTTP_401_UNAUTHORIZED,
94 "/errors/invalid-credentials",
95 _("Invalid credentials"),
96 _("Wrong password."),Lines 94-109 94 "/errors/invalid-credentials",
95 _("Invalid credentials"),
96 _("Wrong password."),
97 )
! 98 try:
! 99 accepted = user._totp_try_setting(
100 pending.secret, int(re.sub(r"\s", "", code))
101 )
! 102 except ValueError:
! 103 accepted = False
! 104 if not accepted:
! 105 return self._problem_response(
106 status.HTTP_401_UNAUTHORIZED,
107 "/errors/mfa-invalid-code",
108 _("Invalid code"),
109 _("The code is not valid."),Lines 107-122 107 "/errors/mfa-invalid-code",
108 _("Invalid code"),
109 _("The code is not valid."),
110 )
! 111 pending.unlink()
! 112 return Response(status_code=status.HTTP_204_NO_CONTENT)
113
! 114 def _uri(self, user, secret):
115 """The link an authentication app expects, as its own spec defines it."""
! 116 issuer = self._issuer()
! 117 label = quote(f"{issuer}:{user.login}", safe=":")
! 118 settings = urlencode(
119 {
120 "secret": secret,
121 "issuer": issuer,
122 "algorithm": ALGORITHM.upper(),Lines 123-133 123 "digits": DIGITS,
124 "period": TIMESTEP,
125 }
126 )
! 127 return f"otpauth://totp/{label}?{settings}"
128
! 129 def _issuer(self):
130 """What the user will read as the account name in their app.
131
132 The address they reach the client at, so that they recognise it. The
133 parameter can hold anything, including the placeholder it ships with,Lines 132-148 132 The address they reach the client at, so that they recognise it. The
133 parameter can hold anything, including the placeholder it ships with,
134 so whatever is not an address falls back to the company.
135 """
! 136 app_url = (
137 self.env["ir.config_parameter"].sudo().get_param("roomdoo_app_url") or ""
138 )
139 # A colon would break the label apart where the app splits it.
! 140 host = urlparse(app_url.strip()).netloc.split(":")[0]
! 141 return host or self.env.company.display_name
142
! 143 def _already_protected_response(self):
! 144 return self._problem_response(
145 status.HTTP_409_CONFLICT,
146 "/errors/mfa-already-enabled",
147 _("Already protected"),
148 _("This account already asks for a code."),Lines 147-156 147 _("Already protected"),
148 _("This account already asks for a code."),
149 )
150
! 151 def _offer_required_response(self):
! 152 return self._problem_response(
153 status.HTTP_409_CONFLICT,
154 "/errors/mfa-setup-required",
155 _("Nothing to confirm"),
156 _("Start again to get a new code to scan."),Lines 155-164 155 _("Nothing to confirm"),
156 _("Start again to get a new code to scan."),
157 )
158
! 159 def _problem_response(self, status_code, type_, title, detail):
! 160 return JSONResponse(
161 status_code=status_code,
162 media_type="application/problem+json",
163 content={
164 "type": type_,pms_fastapi/schemas/two_factor.pyLines 1-16 ! 1 from pydantic import SecretStr
2
! 3 from .base import PmsBaseModel
4
5
! 6 class TwoFactorSetup(PmsBaseModel):
7 """What an authentication app needs to start generating codes."""
8
! 9 uri: str
! 10 secret: str
11
12
! 13 class TwoFactorActivation(PmsBaseModel):
! 14 code: str
! 15 password: SecretStrpms_fastapi/schemas/user.pyLines 23-31 23 phone: str = Field("", alias="phone")
24 lang: str = Field("", alias="lang")
25 image: AnyHttpUrl | None = None
26 defaultPmsProperty: PropertyId | None = None
! 27 twoFactorEnabled: bool = False
28
29 @classmethod
30 def from_res_users(cls, user_record):
31 record = user_record.read()[0]Lines 34-42 34 if user_record.pms_property_id:
35 data["defaultPmsProperty"] = PropertyId.from_pms_property(
36 user_record.pms_property_id
37 )
! 38 data["twoFactorEnabled"] = bool(user_record.totp_enabled)
39 image_url = cls.url_image_pms_api_rest(
40 user_record.env, "res.partner", user_record.partner_id.id, "image_1024"
41 )
42 if image_url: |
jesusVMayor
force-pushed
the
16.0-add-api-2fa-setup
branch
from
September 29, 2026 12:14
8d85a2a to
4799059
Compare
jesusVMayor
force-pushed
the
16.0-add-api-2fa-setup
branch
from
September 29, 2026 18:49
4799059 to
ca85dec
Compare
jesusVMayor
force-pushed
the
16.0-add-api-2fa-setup
branch
2 times, most recently
from
October 6, 2026 10:30
2713897 to
a795db7
Compare
jesusVMayor
marked this pull request as ready for review
October 13, 2026 11:20
DarioLodeiros
force-pushed
the
16.0-add-api-2fa-setup
branch
from
October 13, 2026 19:28
a795db7 to
a7df704
Compare
Protecting an account with a second factor could only be done from the backoffice, which a client of this API has no reason to ever visit. The account is offered a secret, kept server side and never taken back from the client, along with the link an authentication app reads. Confirming it asks for the password again, so that a session left unattended cannot change how the account is protected, and for a code, which proves the app and the server agree. What the user reads as the account name in their app is the address they reach the client at, taken from the configuration; whatever is not an address, including the placeholder the parameter ships with, falls back to the company name rather than reaching the user. Turning it off and managing remembered devices stay in the backoffice.
The client has no way of knowing whether an account already asks for a code, so it cannot decide whether to offer turning it on, nor say that it is already on. Set apart from the rest of the payload because the mapping that fills it drops falsy values, and a flag that is false is exactly what has to reach the client.
DarioLodeiros
force-pushed
the
16.0-add-api-2fa-setup
branch
from
October 13, 2026 19:29
a7df704 to
b281344
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Protecting an account with a second factor could only be done from the
backoffice, which a client of this API has no reason to ever visit.
GET /pmsApi/user/two-factoroffers one: the link an authentication appreads, usually shown as a QR code, and the same secret in text for typing
it by hand. Nothing is protected until it is confirmed.
POST /pmsApi/user/two-factorconfirms it with a code and the password.Decisions worth knowing
The secret is kept server side and never taken back from the client, so what
ends up protecting the account is what the server offered and nothing else.
The password is asked for again on confirmation: a session left unattended
should not be enough to change how the account is protected.
What the user reads as the account name in their authentication app is the
address they reach the client at, taken from configuration and stripped of
scheme and port, since a colon would break the label where the app splits
it. Whatever is not an address — including the placeholder the parameter
ships with — falls back to the company name rather than reaching the user.
Turning the second factor off, and listing or revoking remembered devices,
stay in the backoffice on purpose.
Testing
Seven tests over both endpoints, including one that parses the link the way
an authentication app does.