Skip to content

[16.0] pms_fastapi: turn on a second factor from the client - #159

Merged
DarioLodeiros merged 2 commits into
16.0from
16.0-add-api-2fa-setup
Oct 13, 2026
Merged

DarioLodeiros merged 2 commits into
16.0from
16.0-add-api-2fa-setup

Conversation

@jesusVMayor

Copy link
Copy Markdown
Member

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-factor offers one: the link an authentication app
    reads, 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-factor confirms 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.

@jesusVMayor
jesusVMayor added this pull request to stack #158 September 25, 2026 16:45
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Diff Coverage

Diff: origin/16.0...HEAD, staged and unstaged changes

  • pms_fastapi/models/pms_mfa_setup.py (0.0%): Missing lines 1,4,12-13,15,18-19,21,25-28
  • pms_fastapi/routers/two_factor.py (0.0%): Missing lines 1-5,7-8,10-11,13,19-21,26,29-30,38,41-42,49,54-56,58-63,65-67,71-74,76-79,87-92,98-99,102-105,111-112,114,116-118,127,129,136,140-141,143-144,151-152,159-160
  • pms_fastapi/schemas/two_factor.py (0.0%): Missing lines 1,3,6,9-10,13-15
  • pms_fastapi/schemas/user.py (0.0%): Missing lines 27,38

Summary

  • Total: 91 lines
  • Missing: 91 lines
  • Coverage: 0%

pms_fastapi/models/pms_mfa_setup.py

Lines 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 server

Lines 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.py

Lines 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 QR

Lines 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 cannot

Lines 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.py

Lines 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: SecretStr

pms_fastapi/schemas/user.py

Lines 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
jesusVMayor force-pushed the 16.0-add-api-2fa-setup branch from 8d85a2a to 4799059 Compare September 29, 2026 12:14
@jesusVMayor
jesusVMayor force-pushed the 16.0-add-api-2fa-setup branch from 4799059 to ca85dec Compare September 29, 2026 18:49
@jesusVMayor
jesusVMayor force-pushed the 16.0-add-api-2fa-setup branch 2 times, most recently from 2713897 to a795db7 Compare October 6, 2026 10:30
@jesusVMayor
jesusVMayor marked this pull request as ready for review October 13, 2026 11:20
@DarioLodeiros
DarioLodeiros force-pushed the 16.0-add-api-2fa-setup branch from a795db7 to a7df704 Compare October 13, 2026 19:28
Base automatically changed from 16.0-add-api-2fa-login to 16.0 October 13, 2026 19:29
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
DarioLodeiros force-pushed the 16.0-add-api-2fa-setup branch from a7df704 to b281344 Compare October 13, 2026 19:29
@DarioLodeiros
DarioLodeiros merged commit bc1a3cd into 16.0 Oct 13, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants