]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Feature: two-factor authentication (#8012)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Mon, 18 Nov 2024 18:34:46 +0000 (10:34 -0800)
committerGitHub <noreply@github.com>
Mon, 18 Nov 2024 18:34:46 +0000 (18:34 +0000)
29 files changed:
Pipfile
Pipfile.lock
docs/usage.md
src-ui/messages.xlf
src-ui/src/app/components/app-frame/app-frame.component.spec.ts
src-ui/src/app/components/app-frame/app-frame.component.ts
src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html
src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.spec.ts
src-ui/src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts
src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html
src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.spec.ts
src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts
src-ui/src/app/data/user-profile.ts
src-ui/src/app/data/user.ts
src-ui/src/app/services/permissions.service.spec.ts
src-ui/src/app/services/permissions.service.ts
src-ui/src/app/services/profile.service.spec.ts
src-ui/src/app/services/profile.service.ts
src-ui/src/app/services/rest/user.service.spec.ts
src-ui/src/app/services/rest/user.service.ts
src/documents/management/commands/document_exporter.py
src/documents/templates/mfa/authenticate.html [new file with mode: 0644]
src/documents/tests/test_api_permissions.py
src/documents/tests/test_api_profile.py
src/locale/en_US/LC_MESSAGES/django.po
src/paperless/serialisers.py
src/paperless/settings.py
src/paperless/urls.py
src/paperless/views.py

diff --git a/Pipfile b/Pipfile
index 794af014d82401537b476e1a1e3c01b858716b32..1e79bb604d9e9a778b0a2ed6d0ea79dd065a1c6e 100644 (file)
--- a/Pipfile
+++ b/Pipfile
@@ -8,7 +8,7 @@ dateparser = "~=1.2"
 # WARNING: django does not use semver.
 #          Only patch versions are guaranteed to not introduce breaking changes.
 django = "~=5.1.3"
-django-allauth = {extras = ["socialaccount"], version = "*"}
+django-allauth = {extras = ["mfa", "socialaccount"], version = "*"}
 django-auditlog = "*"
 django-celery-results = "*"
 django-compression-middleware = "*"
index 9377e357530bbea401f58063e5449f7227cbdad4..063ec238950cfff807480b94673e955e8a372b31 100644 (file)
@@ -1,7 +1,7 @@
 {
     "_meta": {
         "hash": {
-            "sha256": "dccf58aea1ba4c0aa4aa93c1cc13881229889db25bc6e5b2384413a7e7e85182"
+            "sha256": "5a7cb70103e8f3931682c73432290f2f4ec2ba06395c8ec076d2d5449c4ff0dd"
         },
         "pipfile-spec": 6,
         "requires": {},
         },
         "django-allauth": {
             "extras": [
+                "mfa",
                 "socialaccount"
             ],
             "hashes": [
             "markers": "python_version >= '3.7'",
             "version": "==1.2.2"
         },
+        "fido2": {
+            "hashes": [
+                "sha256:26100f226d12ced621ca6198528ce17edf67b78df4287aee1285fee3cd5aa9fc",
+                "sha256:6be34c0b9fe85e4911fd2d103cce7ae8ce2f064384a7a2a3bd970b3ef7702931"
+            ],
+            "version": "==1.1.3"
+        },
         "filelock": {
             "hashes": [
                 "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0",
             "index": "pypi",
             "version": "==0.1.9"
         },
+        "qrcode": {
+            "hashes": [
+                "sha256:025ce2b150f7fe4296d116ee9bad455a6643ab4f6e7dce541613a4758cbce347",
+                "sha256:9fc05f03305ad27a709eb742cf3097fa19e6f6f93bb9e2f039c0979190f6f1b1"
+            ],
+            "version": "==8.0"
+        },
         "rapidfuzz": {
             "hashes": [
                 "sha256:00d02cbd75d283c287471b5b3738b3e05c9096150f93f2d2dfa10b3d700f2db9",
index f853bb7f51f8a0f3b63a59ec80faea20c038436a..8f22ec3eb9196856f2f83078ea94978bfa401e9d 100644 (file)
@@ -299,6 +299,12 @@ In order to enable the password reset feature you will need to setup an SMTP bac
 [`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST). If your installation does not have
 [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) set, the reset link included in emails will use the server host.
 
+### Two-factor authentication
+
+Users can enable two-factor authentication (2FA) for their accounts from the 'My Profile' dialog. Opening the dropdown reveals a QR code that can be scanned by a 2FA app (e.g. Google Authenticator) to generate a code. The code must then be entered in the dialog to enable 2FA. If the code is accepted and 2FA is enabled, the user will be shown a set of 10 recovery codes that can be used to login in the event that the 2FA device is lost or unavailable. These codes should be stored securely and cannot be retrieved again. Once enabled, users will be required to enter a code from their 2FA app when logging in.
+
+Should a user lose access to their 2FA device and all recovery codes, a superuser can disable 2FA for the user from the 'Users & Groups' management screen.
+
 ## Workflows
 
 !!! note
index b9aa4e03e275c3770f93450b8fce124a045bfb3d..e988a39cb4934c966348cb143f5e220714e4f8d5 100644 (file)
           <context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
           <context context-type="linenumber">34</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
+          <context context-type="linenumber">124</context>
+        </context-group>
       </trans-unit>
       <trans-unit id="3823219296477075982" datatype="html">
         <source>Discard</source>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context>
-          <context context-type="linenumber">43</context>
+          <context context-type="linenumber">57</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
-          <context context-type="linenumber">99</context>
+          <context context-type="linenumber">184</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
           <context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.html</context>
           <context context-type="linenumber">23</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
+          <context context-type="linenumber">111</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
+          <context context-type="linenumber">127</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
           <context context-type="linenumber">10</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context>
-          <context context-type="linenumber">37</context>
+          <context context-type="linenumber">51</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context>
-          <context context-type="linenumber">42</context>
+          <context context-type="linenumber">56</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
-          <context context-type="linenumber">98</context>
+          <context context-type="linenumber">183</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/select-dialog/select-dialog.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
-          <context context-type="linenumber">159</context>
+          <context context-type="linenumber">173</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2753185112875184719" datatype="html">
         <source>Sidebar views updated</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
-          <context context-type="linenumber">208</context>
+          <context context-type="linenumber">209</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3547923076537026828" datatype="html">
         <source>Error updating sidebar views</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
-          <context context-type="linenumber">211</context>
+          <context context-type="linenumber">212</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2526035785704676448" datatype="html">
         <source>An error occurred while saving update checking settings.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
-          <context context-type="linenumber">232</context>
+          <context context-type="linenumber">233</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4580988005648117665" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
-          <context context-type="linenumber">18</context>
+          <context context-type="linenumber">20</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4249303448466017578" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
-          <context context-type="linenumber">8</context>
+          <context context-type="linenumber">10</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5342432350421167093" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
-          <context context-type="linenumber">28</context>
+          <context context-type="linenumber">30</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3586674587150281199" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
-          <context context-type="linenumber">29</context>
+          <context context-type="linenumber">31</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8204176479746810612" datatype="html">
           <context context-type="linenumber">30</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="8900662509426586619" datatype="html">
+        <source>Two-factor Authentication</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context>
+          <context context-type="linenumber">37</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
+          <context context-type="linenumber">104</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
+          <context context-type="linenumber">138</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="8418597938335066730" datatype="html">
+        <source>Disable Two-factor Authentication</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context>
+          <context context-type="linenumber">39</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context>
+          <context context-type="linenumber">41</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
+          <context context-type="linenumber">169</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
+          <context context-type="linenumber">171</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="1436831433675346331" datatype="html">
         <source>Create new user account</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts</context>
-          <context context-type="linenumber">44</context>
+          <context context-type="linenumber">49</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2887331217965896363" datatype="html">
         <source>Edit user account</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts</context>
-          <context context-type="linenumber">48</context>
+          <context context-type="linenumber">53</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="5872286584705575476" datatype="html">
+        <source>Totp deactivated</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts</context>
+          <context context-type="linenumber">109</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6439190193788239059" datatype="html">
+        <source>Totp deactivation failed</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts</context>
+          <context context-type="linenumber">112</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts</context>
+          <context context-type="linenumber">117</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8419515490539218007" datatype="html">
         <source>Confirm Email</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
-          <context context-type="linenumber">13</context>
+          <context context-type="linenumber">15</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3241357959735682038" datatype="html">
         <source>Confirm Password</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
-          <context context-type="linenumber">23</context>
+          <context context-type="linenumber">25</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7554924397178347823" datatype="html">
         <source>API Auth Token</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
-          <context context-type="linenumber">31</context>
+          <context context-type="linenumber">33</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4323470180912194028" datatype="html">
         <source>Copy</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
-          <context context-type="linenumber">35</context>
+          <context context-type="linenumber">37</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
-          <context context-type="linenumber">42</context>
+          <context context-type="linenumber">44</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
+          <context context-type="linenumber">156</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
         <source>Regenerate auth token</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
-          <context context-type="linenumber">45</context>
+          <context context-type="linenumber">47</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5392341774767336507" datatype="html">
         <source>Copied!</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
-          <context context-type="linenumber">53</context>
+          <context context-type="linenumber">55</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
+          <context context-type="linenumber">163</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
         <source>Warning: changing the token cannot be undone</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
-          <context context-type="linenumber">55</context>
+          <context context-type="linenumber">57</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8935717557476105185" datatype="html">
         <source>Connected social accounts</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
-          <context context-type="linenumber">59</context>
+          <context context-type="linenumber">63</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8383227756109993898" datatype="html">
         <source>Set a password before disconnecting social account.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
-          <context context-type="linenumber">63</context>
+          <context context-type="linenumber">67</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2907016025519254862" datatype="html">
         <source>Disconnect</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
-          <context context-type="linenumber">69</context>
+          <context context-type="linenumber">73</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5322995394400578831" datatype="html">
         <source>Disconnect <x id="INTERPOLATION" equiv-text="{{ account.name }}"/> social account</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
-          <context context-type="linenumber">71</context>
+          <context context-type="linenumber">75</context>
         </context-group>
       </trans-unit>
       <trans-unit id="649824314893051979" datatype="html">
         <source>Warning: disconnecting social accounts cannot be undone</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
-          <context context-type="linenumber">81</context>
+          <context context-type="linenumber">85</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1375396510511350122" datatype="html">
         <source>Connect new social account</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
-          <context context-type="linenumber">86</context>
+          <context context-type="linenumber">90</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="4187671210825254690" datatype="html">
+        <source>Scan the QR code with your authenticator app and then enter the code below</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
+          <context context-type="linenumber">115</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="5867169599865838267" datatype="html">
+        <source>Authenticator secret</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
+          <context context-type="linenumber">118</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="5331198279926709145" datatype="html">
+        <source>You can store this secret and use it to reinstall your authenticator app at a later time.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
+          <context context-type="linenumber">119</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="8186013988289067040" datatype="html">
+        <source>Code</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
+          <context context-type="linenumber">122</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="3176701652604668614" datatype="html">
+        <source>Recovery codes will not be shown again, make sure to save them.</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
+          <context context-type="linenumber">141</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="2722512118372958038" datatype="html">
+        <source>Copy codes</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
+          <context context-type="linenumber">159</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6141884091799403188" datatype="html">
         <source>Emails must match</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
-          <context context-type="linenumber">108</context>
+          <context context-type="linenumber">121</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5281933990298241826" datatype="html">
         <source>Passwords must match</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
-          <context context-type="linenumber">136</context>
+          <context context-type="linenumber">149</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4219429959475101385" datatype="html">
         <source>Profile updated successfully</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
-          <context context-type="linenumber">156</context>
+          <context context-type="linenumber">170</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3417726855410304962" datatype="html">
         <source>Error saving profile</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
-          <context context-type="linenumber">168</context>
+          <context context-type="linenumber">182</context>
         </context-group>
       </trans-unit>
       <trans-unit id="154249228726292516" datatype="html">
         <source>Error generating auth token</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
-          <context context-type="linenumber">185</context>
+          <context context-type="linenumber">199</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4153637646944982460" datatype="html">
         <source>Error disconnecting social account</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
-          <context context-type="linenumber">210</context>
+          <context context-type="linenumber">224</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="5939111172212776886" datatype="html">
+        <source>Error fetching TOTP settings</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
+          <context context-type="linenumber">243</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="1030314492414713260" datatype="html">
+        <source>TOTP activated successfully</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
+          <context context-type="linenumber">263</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="3755006064892435830" datatype="html">
+        <source>Error activating TOTP</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
+          <context context-type="linenumber">265</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
+          <context context-type="linenumber">271</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="5919827473541889422" datatype="html">
+        <source>TOTP deactivated successfully</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
+          <context context-type="linenumber">287</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6214722303383624015" datatype="html">
+        <source>Error deactivating TOTP</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
+          <context context-type="linenumber">289</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
+          <context context-type="linenumber">294</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3797570084942068182" datatype="html">
index 1354a187ea44c00044b99a1f282e86f847de0c16..f440946da32b0255d7102fa6fab5210ef8b21363 100644 (file)
@@ -343,6 +343,7 @@ describe('AppFrameComponent', () => {
     component.editProfile()
     expect(modalSpy).toHaveBeenCalledWith(ProfileEditDialogComponent, {
       backdrop: 'static',
+      size: 'xl',
     })
   })
 
index df6ac65a20335fed8a189e8e0385795421f3b340..83d92756267293313f5681b420cafe3f4a1a12d9 100644 (file)
@@ -136,6 +136,7 @@ export class AppFrameComponent
   editProfile() {
     this.modalService.open(ProfileEditDialogComponent, {
       backdrop: 'static',
+      size: 'xl',
     })
     this.closeMenu()
   }
index ca834a3ade5104fe05900e421b6a1f88c476a964..a2b3db67dac4581bb51bc79458eeb8a8036d8609 100644 (file)
           </div>
 
           <pngx-input-select i18n-title title="Groups" [items]="groups" multiple="true" formControlName="groups"></pngx-input-select>
+
+          @if (object?.is_mfa_enabled && currentUserIsSuperUser) {
+            <label class="form-label" i18n>Two-factor Authentication</label>
+            <pngx-confirm-button
+              label="Disable Two-factor Authentication"
+              i18n-label
+              title="Disable Two-factor Authentication"
+              i18n-title
+              buttonClasses="btn-outline-danger btn-sm"
+              iconName="trash"
+              [disabled]="totpLoading"
+              (confirm)="deactivateTotp()">
+            </pngx-confirm-button>
+          }
         </div>
         <div class="col">
           <pngx-permissions-select i18n-title title="Permissions" formControlName="user_permissions" [error]="error?.user_permissions" [inheritedPermissions]="inheritedPermissions"></pngx-permissions-select>
index 96a0044fe6e017d54510ca33e928bf86b2734c08..5adaf3388b079908e2bcfa09ade26eca68f923fe 100644 (file)
@@ -7,7 +7,7 @@ import {
 } from '@angular/forms'
 import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
 import { NgSelectModule } from '@ng-select/ng-select'
-import { of } from 'rxjs'
+import { of, throwError } from 'rxjs'
 import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
 import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
 import { GroupService } from 'src/app/services/rest/group.service'
@@ -21,10 +21,15 @@ import { EditDialogMode } from '../edit-dialog.component'
 import { UserEditDialogComponent } from './user-edit-dialog.component'
 import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
 import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
+import { ToastService } from 'src/app/services/toast.service'
+import { UserService } from 'src/app/services/rest/user.service'
+import { PermissionsService } from 'src/app/services/permissions.service'
 
 describe('UserEditDialogComponent', () => {
   let component: UserEditDialogComponent
   let settingsService: SettingsService
+  let permissionsService: PermissionsService
+  let toastService: ToastService
   let fixture: ComponentFixture<UserEditDialogComponent>
 
   beforeEach(async () => {
@@ -71,6 +76,8 @@ describe('UserEditDialogComponent', () => {
     fixture = TestBed.createComponent(UserEditDialogComponent)
     settingsService = TestBed.inject(SettingsService)
     settingsService.currentUser = { id: 99, username: 'user99' }
+    permissionsService = TestBed.inject(PermissionsService)
+    toastService = TestBed.inject(ToastService)
     component = fixture.componentInstance
 
     fixture.detectChanges()
@@ -121,4 +128,38 @@ describe('UserEditDialogComponent', () => {
     component.save()
     expect(component.passwordIsSet).toBeTruthy()
   })
+
+  it('should support deactivation of TOTP', () => {
+    component.object = { id: 99, username: 'user99' }
+    const deactivateSpy = jest.spyOn(
+      component['service'] as UserService,
+      'deactivateTotp'
+    )
+    const toastErrorSpy = jest.spyOn(toastService, 'showError')
+    const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
+    deactivateSpy.mockReturnValueOnce(throwError(() => new Error('error')))
+    component.deactivateTotp()
+    expect(deactivateSpy).toHaveBeenCalled()
+    expect(toastErrorSpy).toHaveBeenCalled()
+
+    deactivateSpy.mockReturnValueOnce(of(false))
+    component.deactivateTotp()
+    expect(deactivateSpy).toHaveBeenCalled()
+    expect(toastErrorSpy).toHaveBeenCalled()
+
+    deactivateSpy.mockReturnValueOnce(of(true))
+    component.deactivateTotp()
+    expect(deactivateSpy).toHaveBeenCalled()
+    expect(toastInfoSpy).toHaveBeenCalled()
+  })
+
+  it('should check superuser status of current user', () => {
+    expect(component.currentUserIsSuperUser).toBeFalsy()
+    permissionsService.initialize([], {
+      id: 99,
+      username: 'user99',
+      is_superuser: true,
+    })
+    expect(component.currentUserIsSuperUser).toBeTruthy()
+  })
 })
index baadfa54178a370636cfe71f1642eed936474551..acd327d3ada268cb0bba349e2d44aecd7cf2a7a5 100644 (file)
@@ -5,9 +5,11 @@ import { first } from 'rxjs'
 import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
 import { Group } from 'src/app/data/group'
 import { User } from 'src/app/data/user'
+import { PermissionsService } from 'src/app/services/permissions.service'
 import { GroupService } from 'src/app/services/rest/group.service'
 import { UserService } from 'src/app/services/rest/user.service'
 import { SettingsService } from 'src/app/services/settings.service'
+import { ToastService } from 'src/app/services/toast.service'
 
 @Component({
   selector: 'pngx-user-edit-dialog',
@@ -20,12 +22,15 @@ export class UserEditDialogComponent
 {
   groups: Group[]
   passwordIsSet: boolean = false
+  public totpLoading: boolean = false
 
   constructor(
     service: UserService,
     activeModal: NgbActiveModal,
     groupsService: GroupService,
-    settingsService: SettingsService
+    settingsService: SettingsService,
+    private toastService: ToastService,
+    private permissionsService: PermissionsService
   ) {
     super(service, activeModal, service, settingsService)
 
@@ -87,4 +92,30 @@ export class UserEditDialogComponent
         .length > 0
     super.save()
   }
+
+  get currentUserIsSuperUser(): boolean {
+    return this.permissionsService.isSuperUser()
+  }
+
+  deactivateTotp() {
+    this.totpLoading = true
+    ;(this.service as UserService)
+      .deactivateTotp(this.object)
+      .pipe(first())
+      .subscribe({
+        next: (result) => {
+          this.totpLoading = false
+          if (result) {
+            this.toastService.showInfo($localize`Totp deactivated`)
+            this.object.is_mfa_enabled = false
+          } else {
+            this.toastService.showError($localize`Totp deactivation failed`)
+          }
+        },
+        error: (e) => {
+          this.totpLoading = false
+          this.toastService.showError($localize`Totp deactivation failed`, e)
+        },
+      })
+  }
 }
index 713d68864d276e20a065b30ec1ae10963e7924ce..f9d57baf3ee73a9d6144aefddd8a99c4ce4420f2 100644 (file)
     </button>
   </div>
   <div class="modal-body">
-    <pngx-input-text i18n-title title="Email" formControlName="email" (keyup)="onEmailKeyUp($event)" [error]="error?.email"></pngx-input-text>
-    <div ngbAccordion>
-      <div ngbAccordionItem="first" [collapsed]="!showEmailConfirm" class="border-0 bg-transparent">
-        <div ngbAccordionCollapse>
-          <div ngbAccordionBody class="p-0 pb-3">
-            <pngx-input-text i18n-title title="Confirm Email" formControlName="email_confirm" (keyup)="onEmailConfirmKeyUp($event)" autocomplete="email" [error]="error?.email_confirm"></pngx-input-text>
+    <div class="row">
+      <div class="col-12 col-md-6">
+        <pngx-input-text i18n-title title="Email" formControlName="email" (keyup)="onEmailKeyUp($event)" [error]="error?.email"></pngx-input-text>
+        <div ngbAccordion>
+          <div ngbAccordionItem="first" [collapsed]="!showEmailConfirm" class="border-0 bg-transparent">
+            <div ngbAccordionCollapse>
+              <div ngbAccordionBody class="p-0 pb-3">
+                <pngx-input-text i18n-title title="Confirm Email" formControlName="email_confirm" (keyup)="onEmailConfirmKeyUp($event)" autocomplete="email" [error]="error?.email_confirm"></pngx-input-text>
+              </div>
+            </div>
           </div>
         </div>
-      </div>
-    </div>
-    <pngx-input-password i18n-title title="Password" formControlName="password" (keyup)="onPasswordKeyUp($event)" [showReveal]="true" autocomplete="current-password" [error]="error?.password"></pngx-input-password>
-    <div ngbAccordion>
-      <div ngbAccordionItem="first" [collapsed]="!showPasswordConfirm" class="border-0 bg-transparent">
-        <div ngbAccordionCollapse>
-          <div ngbAccordionBody class="p-0 pb-3">
-            <pngx-input-password i18n-title title="Confirm Password" formControlName="password_confirm" (keyup)="onPasswordConfirmKeyUp($event)" autocomplete="new-password" [error]="error?.password_confirm"></pngx-input-password>
-          </div>
-        </div>
-      </div>
-    </div>
-    <pngx-input-text i18n-title title="First name" formControlName="first_name" [error]="error?.first_name"></pngx-input-text>
-    <pngx-input-text i18n-title title="Last name" formControlName="last_name" [error]="error?.first_name"></pngx-input-text>
-    <div class="mb-3">
-      <label class="form-label" i18n>API Auth Token</label>
-      <div class="position-relative">
-        <div class="input-group">
-          <input type="text" class="form-control" formControlName="auth_token" readonly>
-          <button type="button" class="btn btn-outline-secondary" (click)="copyAuthToken()" i18n-title title="Copy">
-              @if (!copied) {
-                <i-bs width="1em" height="1em" name="clipboard-fill"></i-bs>
-              }
-              @if (copied) {
-                <i-bs width="1em" height="1em" name="clipboard-check-fill"></i-bs>
-              }
-              <span class="visually-hidden" i18n>Copy</span>
-            </button>
-            <pngx-confirm-button
-              title="Regenerate auth token"
-              i18n-title
-              buttonClasses=" btn-outline-secondary"
-              iconName="arrow-repeat"
-              [disabled]="!hasUsablePassword"
-              (confirm)="generateAuthToken()">
-            </pngx-confirm-button>
+        <pngx-input-password i18n-title title="Password" formControlName="password" (keyup)="onPasswordKeyUp($event)" [showReveal]="true" autocomplete="current-password" [error]="error?.password"></pngx-input-password>
+        <div ngbAccordion>
+          <div ngbAccordionItem="first" [collapsed]="!showPasswordConfirm" class="border-0 bg-transparent">
+            <div ngbAccordionCollapse>
+              <div ngbAccordionBody class="p-0 pb-3">
+                <pngx-input-password i18n-title title="Confirm Password" formControlName="password_confirm" (keyup)="onPasswordConfirmKeyUp($event)" autocomplete="new-password" [error]="error?.password_confirm"></pngx-input-password>
+              </div>
+            </div>
           </div>
-          <span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied" i18n>Copied!</span>
         </div>
-        <div class="form-text text-muted text-end fst-italic" i18n>Warning: changing the token cannot be undone</div>
-      </div>
-      @if (socialAccounts?.length > 0) {
+        <pngx-input-text i18n-title title="First name" formControlName="first_name" [error]="error?.first_name"></pngx-input-text>
+        <pngx-input-text i18n-title title="Last name" formControlName="last_name" [error]="error?.first_name"></pngx-input-text>
         <div class="mb-3">
-          <p i18n>Connected social accounts</p>
-          <ul class="list-group">
-            @for (account of socialAccounts; track account.id) {
-              <li class="list-group-item"
-                ngbPopover="Set a password before disconnecting social account."
-                i18n-ngbPopover
-                [disablePopover]="hasUsablePassword"
-                triggers="mouseenter:mouseleave">
-                {{account.name}} ({{account.provider}})
+          <label class="form-label" i18n>API Auth Token</label>
+          <div class="position-relative">
+            <div class="input-group">
+              <input type="text" class="form-control" formControlName="auth_token" readonly>
+              <button type="button" class="btn btn-outline-secondary" (click)="copyAuthToken()" i18n-title title="Copy">
+                  @if (!copied) {
+                    <i-bs width="1em" height="1em" name="clipboard-fill"></i-bs>
+                  }
+                  @if (copied) {
+                    <i-bs width="1em" height="1em" name="clipboard-check-fill"></i-bs>
+                  }
+                  <span class="visually-hidden" i18n>Copy</span>
+                </button>
                 <pngx-confirm-button
-                  label="Disconnect"
-                  i18n-label
-                  title="Disconnect {{ account.name }} social account"
+                  title="Regenerate auth token"
                   i18n-title
-                  buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline"
-                  iconName="trash"
+                  buttonClasses=" btn-outline-secondary"
+                  iconName="arrow-repeat"
                   [disabled]="!hasUsablePassword"
-                  (confirm)="disconnectSocialAccount(account.id)">
+                  (confirm)="generateAuthToken()">
                 </pngx-confirm-button>
-              </li>
-            }
-          </ul>
-          <div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div>
-        </div>
-      }
-      @if (socialAccountProviders?.length > 0) {
-        <div class="mb-3">
-          <p i18n>Connect new social account</p>
-          <div class="list-group">
-            @for (provider of socialAccountProviders; track provider.name) {
-              <a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer">
-                {{provider.name}}&nbsp;<i-bs class="pb-1 ps-1" name="box-arrow-up-right"></i-bs>
-              </a>
-            }
+              </div>
+              <span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied" i18n>Copied!</span>
+            </div>
+            <div class="form-text text-muted text-end fst-italic" i18n>Warning: changing the token cannot be undone</div>
           </div>
-        </div>
-      }
+      </div>
+      <div class="col-12 col-md-6">
+        @if (socialAccounts?.length > 0) {
+          <div class="mb-3">
+            <p i18n>Connected social accounts</p>
+            <ul class="list-group">
+              @for (account of socialAccounts; track account.id) {
+                <li class="list-group-item"
+                  ngbPopover="Set a password before disconnecting social account."
+                  i18n-ngbPopover
+                  [disablePopover]="hasUsablePassword"
+                  triggers="mouseenter:mouseleave">
+                  {{account.name}} ({{account.provider}})
+                  <pngx-confirm-button
+                    label="Disconnect"
+                    i18n-label
+                    title="Disconnect {{ account.name }} social account"
+                    i18n-title
+                    buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline"
+                    iconName="trash"
+                    [disabled]="!hasUsablePassword"
+                    (confirm)="disconnectSocialAccount(account.id)">
+                  </pngx-confirm-button>
+                </li>
+              }
+            </ul>
+            <div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div>
+          </div>
+        }
+        @if (socialAccountProviders?.length > 0) {
+          <div class="mb-3">
+            <p i18n>Connect new social account</p>
+            <div class="list-group">
+              @for (provider of socialAccountProviders; track provider.name) {
+                <a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer">
+                  {{provider.name}}&nbsp;<i-bs class="pb-1 ps-1" name="box-arrow-up-right"></i-bs>
+                </a>
+              }
+            </div>
+          </div>
+        }
+        @if (!isTotpEnabled) {
+          <div ngbAccordion>
+            <div ngbAccordionItem>
+              <h2 ngbAccordionHeader>
+                <button ngbAccordionButton (click)="gettotpSettings()" i18n>Two-factor Authentication</button>
+              </h2>
+              <div ngbAccordionCollapse>
+                <div ngbAccordionBody>
+                  <ng-template>
+                    @if (totpSettingsLoading) {
+                      <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
+                      <div class="visually-hidden" i18n>Loading...</div>
+                    } @else if (totpSettings) {
+                      <figure class="figure">
+                        <div class="bg-white d-inline-block" [innerHTML]="totpSettings.qr_svg | safeHtml"></div>
+                        <figcaption class="figure-caption text-end mt-2" i18n>Scan the QR code with your authenticator app and then enter the code below</figcaption>
+                      </figure>
+                      <p>
+                        <ng-container i18n>Authenticator secret</ng-container>: <code>{{totpSettings.secret}}</code>.
+                        <ng-container i18n>You can store this secret and use it to reinstall your authenticator app at a later time.</ng-container>
+                      </p>
+                      <div class="input-group mb-3">
+                        <input type="text" class="form-control" formControlName="totp_code" placeholder="Code" i18n-placeholder>
+                        <button type="button" class="btn btn-primary ml-auto" (click)="activateTotp()" [disabled]="totpLoading">
+                          <ng-container i18n>Enable</ng-container>
+                          @if (totpLoading) {
+                            <div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
+                            <div class="visually-hidden" i18n>Loading...</div>
+                          }
+                        </button>
+                      </div>
+                    }
+                  </ng-template>
+                </div>
+              </div>
+            </div>
+          </div>
+        } @else {
+          <label class="d-block mb-2" i18n>Two-factor Authentication</label>
+          @if (recoveryCodes) {
+            <div class="alert alert-warning" role="alert">
+              <i-bs name="exclamation-triangle"></i-bs>&nbsp;<ng-container i18n>Recovery codes will not be shown again, make sure to save them.</ng-container>
+            </div>
+            <div class="d-flex flex-row align-items-start mb-3">
+              <ul class="list-group w-50">
+                  @for (code of recoveryCodes; track code; let i = $index) {
+                    @if (i % 2 === 0) {
+                      <li class="list-group-item d-flex justify-content-around align-items-center">
+                        <code>{{code}}</code>
+                        @if (recoveryCodes[i + 1]) {
+                          <code>{{recoveryCodes[i + 1]}}</code>
+                        }
+                      </li>
+                    }
+                  }
+              </ul>
+              <button type="button" class="btn btn-sm btn-outline-secondary ms-2" (click)="copyRecoveryCodes()" i18n-title title="Copy">
+                @if (!codesCopied) {
+                  <i-bs width="1em" height="1em" name="clipboard-fill"></i-bs>
+                  &nbsp;<span i18n>Copy codes</span>
+                }
+                @if (codesCopied) {
+                  <i-bs width="1em" height="1em" name="clipboard-check-fill" class="text-primary"></i-bs>
+                  &nbsp;<span class="text-primary" i18n>Copied!</span>
+                }
+              </button>
+            </div>
+          }
+          <pngx-confirm-button
+            label="Disable Two-factor Authentication"
+            i18n-label
+            title="Disable Two-factor Authentication"
+            i18n-title
+            buttonClasses="btn-outline-danger btn-sm"
+            iconName="trash"
+            [disabled]="totpLoading"
+            (confirm)="deactivateTotp()">
+          </pngx-confirm-button>
+        }
+      </div>
+    </div>
     </div>
     <div class="modal-footer">
       <button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
index d0af0f2ade5d1ede988d6c475dbf44e43685bd76..aa793941e41eb2d61071960113e94d6f4a8958a1 100644 (file)
@@ -294,4 +294,85 @@ describe('ProfileEditDialogComponent', () => {
     expect(disconnectSpy).toHaveBeenCalled()
     expect(component.socialAccounts).not.toContainEqual(socialAccount)
   })
+
+  it('should get totp settings', () => {
+    const settings = {
+      url: 'http://localhost/',
+      qr_svg: 'svg',
+      secret: 'secret',
+    }
+    const getSpy = jest.spyOn(profileService, 'getTotpSettings')
+    const toastSpy = jest.spyOn(toastService, 'showError')
+    getSpy.mockReturnValueOnce(
+      throwError(() => new Error('failed to get settings'))
+    )
+    component.gettotpSettings()
+    expect(getSpy).toHaveBeenCalled()
+    expect(toastSpy).toHaveBeenCalled()
+
+    getSpy.mockReturnValue(of(settings))
+    component.gettotpSettings()
+    expect(getSpy).toHaveBeenCalled()
+    expect(component.totpSettings).toEqual(settings)
+  })
+
+  it('should activate totp', () => {
+    const activateSpy = jest.spyOn(profileService, 'activateTotp')
+    const toastErrorSpy = jest.spyOn(toastService, 'showError')
+    const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
+    const error = new Error('failed to activate totp')
+    activateSpy.mockReturnValueOnce(throwError(() => error))
+    component.totpSettings = {
+      url: 'http://localhost/',
+      qr_svg: 'svg',
+      secret: 'secret',
+    }
+    component.form.get('totp_code').patchValue('123456')
+    component.activateTotp()
+    expect(activateSpy).toHaveBeenCalledWith(
+      component.totpSettings.secret,
+      component.form.get('totp_code').value
+    )
+    expect(toastErrorSpy).toHaveBeenCalled()
+
+    activateSpy.mockReturnValueOnce(of({ success: false, recovery_codes: [] }))
+    component.activateTotp()
+    expect(toastErrorSpy).toHaveBeenCalledWith('Error activating TOTP', error)
+
+    activateSpy.mockReturnValueOnce(
+      of({ success: true, recovery_codes: ['1', '2', '3'] })
+    )
+    component.activateTotp()
+    expect(toastInfoSpy).toHaveBeenCalled()
+    expect(component.isTotpEnabled).toBeTruthy()
+    expect(component.recoveryCodes).toEqual(['1', '2', '3'])
+  })
+
+  it('should deactivate totp', () => {
+    const deactivateSpy = jest.spyOn(profileService, 'deactivateTotp')
+    const toastErrorSpy = jest.spyOn(toastService, 'showError')
+    const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
+    const error = new Error('failed to deactivate totp')
+    deactivateSpy.mockReturnValueOnce(throwError(() => error))
+    component.deactivateTotp()
+    expect(deactivateSpy).toHaveBeenCalled()
+    expect(toastErrorSpy).toHaveBeenCalled()
+
+    deactivateSpy.mockReturnValueOnce(of(false))
+    component.deactivateTotp()
+    expect(toastErrorSpy).toHaveBeenCalledWith('Error deactivating TOTP', error)
+
+    deactivateSpy.mockReturnValueOnce(of(true))
+    component.deactivateTotp()
+    expect(toastInfoSpy).toHaveBeenCalled()
+    expect(component.isTotpEnabled).toBeFalsy()
+  })
+
+  it('should copy recovery codes', fakeAsync(() => {
+    const copySpy = jest.spyOn(clipboard, 'copy')
+    component.recoveryCodes = ['1', '2', '3']
+    component.copyRecoveryCodes()
+    expect(copySpy).toHaveBeenCalledWith('1\n2\n3')
+    tick(3000)
+  }))
 })
index 77cba45057574d148035afc2ef72fd7a14afc233..a4dbaf7d6ef0d76e4d010571c1b41852981b29f8 100644 (file)
@@ -2,7 +2,11 @@ import { Component, OnDestroy, OnInit } from '@angular/core'
 import { FormControl, FormGroup } from '@angular/forms'
 import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
 import { ProfileService } from 'src/app/services/profile.service'
-import { SocialAccount, SocialAccountProvider } from 'src/app/data/user-profile'
+import {
+  TotpSettings,
+  SocialAccount,
+  SocialAccountProvider,
+} from 'src/app/data/user-profile'
 import { ToastService } from 'src/app/services/toast.service'
 import { Subject, takeUntil } from 'rxjs'
 import { Clipboard } from '@angular/cdk/clipboard'
@@ -25,6 +29,7 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
     first_name: new FormControl(''),
     last_name: new FormControl(''),
     auth_token: new FormControl(''),
+    totp_code: new FormControl(''),
   })
 
   private currentPassword: string
@@ -38,7 +43,14 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
   private emailConfirm: string
   public showEmailConfirm: boolean = false
 
+  public isTotpEnabled: boolean = false
+  public totpSettings: TotpSettings
+  public totpSettingsLoading: boolean = false
+  public totpLoading: boolean = false
+  public recoveryCodes: string[]
+
   public copied: boolean = false
+  public codesCopied: boolean = false
 
   public socialAccounts: SocialAccount[] = []
   public socialAccountProviders: SocialAccountProvider[] = []
@@ -70,6 +82,7 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
           this.onPasswordChange()
         })
         this.socialAccounts = profile.social_accounts
+        this.isTotpEnabled = profile.is_mfa_enabled
       })
 
     this.profileService
@@ -147,6 +160,7 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
     const passwordChanged =
       this.newPassword && this.currentPassword !== this.newPassword
     const profile = Object.assign({}, this.form.value)
+    delete profile.totp_code
     this.networkActive = true
     this.profileService
       .update(profile)
@@ -213,4 +227,81 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
         },
       })
   }
+
+  public gettotpSettings(): void {
+    this.totpSettingsLoading = true
+    this.profileService
+      .getTotpSettings()
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe({
+        next: (totpSettings) => {
+          this.totpSettingsLoading = false
+          this.totpSettings = totpSettings
+        },
+        error: (error) => {
+          this.toastService.showError(
+            $localize`Error fetching TOTP settings`,
+            error
+          )
+          this.totpSettingsLoading = false
+        },
+      })
+  }
+
+  public activateTotp(): void {
+    this.totpLoading = true
+    this.form.get('totp_code').disable()
+    this.profileService
+      .activateTotp(this.totpSettings.secret, this.form.get('totp_code').value)
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe({
+        next: (activationResponse) => {
+          this.totpLoading = false
+          this.isTotpEnabled = activationResponse.success
+          this.recoveryCodes = activationResponse.recovery_codes
+          this.form.get('totp_code').enable()
+          if (activationResponse.success) {
+            this.toastService.showInfo($localize`TOTP activated successfully`)
+          } else {
+            this.toastService.showError($localize`Error activating TOTP`)
+          }
+        },
+        error: (error) => {
+          this.totpLoading = false
+          this.form.get('totp_code').enable()
+          this.toastService.showError($localize`Error activating TOTP`, error)
+        },
+      })
+  }
+
+  public deactivateTotp(): void {
+    this.totpLoading = true
+    this.profileService
+      .deactivateTotp()
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe({
+        next: (success) => {
+          this.totpLoading = false
+          this.isTotpEnabled = !success
+          this.recoveryCodes = null
+          if (success) {
+            this.toastService.showInfo($localize`TOTP deactivated successfully`)
+          } else {
+            this.toastService.showError($localize`Error deactivating TOTP`)
+          }
+        },
+        error: (error) => {
+          this.totpLoading = false
+          this.toastService.showError($localize`Error deactivating TOTP`, error)
+        },
+      })
+  }
+
+  public copyRecoveryCodes(): void {
+    this.clipboard.copy(this.recoveryCodes.join('\n'))
+    this.codesCopied = true
+    setTimeout(() => {
+      this.codesCopied = false
+    }, 3000)
+  }
 }
index 554f6f0e1123c5a2c17d77342cd008081e2f9cf5..a07163233447a750448e3dee98fa5911c96144d2 100644 (file)
@@ -17,4 +17,11 @@ export interface PaperlessUserProfile {
   auth_token?: string
   social_accounts?: SocialAccount[]
   has_usable_password?: boolean
+  is_mfa_enabled?: boolean
+}
+
+export interface TotpSettings {
+  url: string
+  qr_svg: string
+  secret: string
 }
index 49216a274247003dd0644e5072834d20d0e119f8..6bf051c254a5d0f2bcebba824e71d464bc76b3b2 100644 (file)
@@ -11,4 +11,5 @@ export interface User extends ObjectWithId {
   groups?: number[] // Group[]
   user_permissions?: string[]
   inherited_permissions?: string[]
+  is_mfa_enabled?: boolean
 }
index f4e01945e36eecf68420b5ede4984e60590dc0aa..ecbdb6f1b5ef359c113c875c5d7e4dc5df1560c4 100644 (file)
@@ -439,4 +439,25 @@ describe('PermissionsService', () => {
 
     expect(permissionsService.isAdmin()).toBeFalsy()
   })
+
+  it('correctly checks superuser status', () => {
+    permissionsService.initialize([], {
+      username: 'testuser',
+      last_name: 'User',
+      first_name: 'Test',
+      id: 1,
+      is_superuser: true,
+    })
+
+    expect(permissionsService.isSuperUser()).toBeTruthy()
+
+    permissionsService.initialize([], {
+      username: 'testuser',
+      last_name: 'User',
+      first_name: 'Test',
+      id: 1,
+    })
+
+    expect(permissionsService.isSuperUser()).toBeFalsy()
+  })
 })
index c80bc763df76da286c6dc1ba22eace9e9848e1c1..3d88b10cce6e7882dd3efaaf5e21aab224111803 100644 (file)
@@ -56,6 +56,10 @@ export class PermissionsService {
     return this.currentUser?.is_staff
   }
 
+  public isSuperUser(): boolean {
+    return this.currentUser?.is_superuser
+  }
+
   public currentUserOwnsObject(object: ObjectWithPermissions): boolean {
     return (
       !object ||
index beb7e9ad53093bdd9365eaf33ea8e85d926b1dbc..b7b85ee357ac64bafd1f2dc8f8ba1dea5877d0c8 100644 (file)
@@ -72,4 +72,32 @@ describe('ProfileService', () => {
     )
     expect(req.request.method).toEqual('GET')
   })
+
+  it('calls get totp settings endpoint', () => {
+    service.getTotpSettings().subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}profile/totp/`
+    )
+    expect(req.request.method).toEqual('GET')
+  })
+
+  it('calls activate totp endpoint', () => {
+    service.activateTotp('secret', 'code').subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}profile/totp/`
+    )
+    expect(req.request.method).toEqual('POST')
+    expect(req.request.body).toEqual({
+      secret: 'secret',
+      code: 'code',
+    })
+  })
+
+  it('calls deactivate totp endpoint', () => {
+    service.deactivateTotp().subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}profile/totp/`
+    )
+    expect(req.request.method).toEqual('DELETE')
+  })
 })
index 32e06cce0e34a60f3f52fcbce4e6a89badace7ed..09839d0126b8b91cf910d6f90777f40266a634eb 100644 (file)
@@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http'
 import { Injectable } from '@angular/core'
 import { Observable } from 'rxjs'
 import {
+  TotpSettings,
   PaperlessUserProfile,
   SocialAccountProvider,
 } from '../data/user-profile'
@@ -47,4 +48,30 @@ export class ProfileService {
       `${environment.apiBaseUrl}${this.endpoint}/social_account_providers/`
     )
   }
+
+  getTotpSettings(): Observable<TotpSettings> {
+    return this.http.get<TotpSettings>(
+      `${environment.apiBaseUrl}${this.endpoint}/totp/`
+    )
+  }
+
+  activateTotp(
+    totpSecret: string,
+    totpCode: string
+  ): Observable<{ success: boolean; recovery_codes: string[] }> {
+    return this.http.post<{ success: boolean; recovery_codes: string[] }>(
+      `${environment.apiBaseUrl}${this.endpoint}/totp/`,
+      {
+        secret: totpSecret,
+        code: totpCode,
+      }
+    )
+  }
+
+  deactivateTotp(): Observable<boolean> {
+    return this.http.delete<boolean>(
+      `${environment.apiBaseUrl}${this.endpoint}/totp/`,
+      {}
+    )
+  }
 }
index acf66340a970cce6c2a7bd68df8fbeb571683e76..3fd682d4e83d90fa03000ab29ee7e1cefe851187 100644 (file)
@@ -160,6 +160,18 @@ const user = {
 commonAbstractNameFilterPaperlessServiceTests(endpoint, UserService)
 
 describe('Additional service tests for UserService', () => {
+  beforeEach(() => {
+    // Dont need to setup again
+
+    httpTestingController = TestBed.inject(HttpTestingController)
+    service = TestBed.inject(UserService)
+  })
+
+  afterEach(() => {
+    subscription?.unsubscribe()
+    httpTestingController.verify()
+  })
+
   it('should retain permissions on update', () => {
     subscription = service.listAll().subscribe()
     let req = httpTestingController.expectOne(
@@ -179,15 +191,11 @@ describe('Additional service tests for UserService', () => {
     )
   })
 
-  beforeEach(() => {
-    // Dont need to setup again
-
-    httpTestingController = TestBed.inject(HttpTestingController)
-    service = TestBed.inject(UserService)
-  })
-
-  afterEach(() => {
-    subscription?.unsubscribe()
-    httpTestingController.verify()
+  it('should deactivate totp', () => {
+    subscription = service.deactivateTotp(user).subscribe()
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}${endpoint}/${user.id}/deactivate_totp/`
+    )
+    expect(req.request.method).toEqual('POST')
   })
 })
index 4fb02b1f7f1a6f19884f4dbea6515f5a5379112b..ded7ae248689c471ce27f7c05b1f0659e07a91e3 100644 (file)
@@ -5,6 +5,7 @@ import { User } from 'src/app/data/user'
 import { PermissionsService } from '../permissions.service'
 import { AbstractNameFilterService } from './abstract-name-filter-service'
 
+const endpoint = 'users'
 @Injectable({
   providedIn: 'root',
 })
@@ -13,7 +14,7 @@ export class UserService extends AbstractNameFilterService<User> {
     http: HttpClient,
     private permissionService: PermissionsService
   ) {
-    super(http, 'users')
+    super(http, endpoint)
   }
 
   update(o: User): Observable<User> {
@@ -31,4 +32,11 @@ export class UserService extends AbstractNameFilterService<User> {
       })
     )
   }
+
+  deactivateTotp(u: User): Observable<boolean> {
+    return this.http.post<boolean>(
+      `${this.getResourceUrl(u.id, 'deactivate_totp')}`,
+      null
+    )
+  }
 }
index 79d7cca6ff12eb493fbdb34bfc17fa8f1a65cde9..f49008cc78eaf9f2b3b796995913c3b62e479e99 100644 (file)
@@ -8,6 +8,7 @@ from pathlib import Path
 from typing import TYPE_CHECKING
 
 import tqdm
+from allauth.mfa.models import Authenticator
 from allauth.socialaccount.models import SocialAccount
 from allauth.socialaccount.models import SocialApp
 from allauth.socialaccount.models import SocialToken
@@ -270,6 +271,7 @@ class Command(CryptMixin, BaseCommand):
             "social_accounts": SocialAccount.objects.all(),
             "social_apps": SocialApp.objects.all(),
             "social_tokens": SocialToken.objects.all(),
+            "authenticators": Authenticator.objects.all(),
         }
 
         if settings.AUDIT_LOG_ENABLED:
diff --git a/src/documents/templates/mfa/authenticate.html b/src/documents/templates/mfa/authenticate.html
new file mode 100644 (file)
index 0000000..e6d54b8
--- /dev/null
@@ -0,0 +1,35 @@
+{% extends "paperless-ngx/base.html" %}
+{% load i18n %}
+{% load allauth %}
+{% load allauth static %}
+
+{% block head_title %}
+    {% trans "Paperless-ngx Two-Factor Authentication" %}
+{% endblock head_title %}
+
+{% block form_top_content %}
+       <p>
+               {% blocktranslate %}Your account is protected by two-factor authentication. Please enter an authenticator code:{% endblocktranslate %}
+       </p>
+{% endblock form_top_content %}
+
+{% block form_content %}
+{% translate "Code" as i18n_code %}
+<div class="form-floating">
+               <input type="code" name="code" id="inputCode" autocomplete="one-time-code" placeholder="{{ i18n_code }}" class="form-control" required>
+               <label for="inputCode">{{ i18n_code }}</label>
+</div>
+<div class="d-grid mt-3">
+               <button class="btn btn-lg btn-primary" type="submit">{% translate "Sign in" %}</button>
+               <button class="btn btn-lg btn-secondary mt-2" type="submit" form="logout-from-stage">{% translate "Cancel" %}</button>
+</div>
+{% endblock form_content %}
+
+{% block after_form_content %}
+<form id="logout-from-stage"
+method="post"
+action="{% url 'account_logout' %}">
+<input type="hidden" name="next" value="{% url 'account_login' %}">
+{% csrf_token %}
+</form>
+{% endblock after_form_content %}
index 7708b85413ee5a6947448efa1dd0dec539e0b398..eeea830cbab42a068be15dbc28f53ae3ffb6ca18 100644 (file)
@@ -1,5 +1,6 @@
 import json
 
+from allauth.mfa.models import Authenticator
 from django.contrib.auth.models import Group
 from django.contrib.auth.models import Permission
 from django.contrib.auth.models import User
@@ -601,6 +602,59 @@ class TestApiUser(DirectoriesMixin, APITestCase):
         self.assertEqual(returned_user2.first_name, "Updated Name 2")
         self.assertNotEqual(returned_user2.password, initial_password)
 
+    def test_deactivate_totp(self):
+        """
+        GIVEN:
+            - Existing user account with TOTP enabled
+        WHEN:
+            - API request by a superuser is made to deactivate TOTP
+            - API request by a regular user is made to deactivate TOTP
+        THEN:
+            - TOTP is deactivated, if exists
+            - Regular user is forbidden from deactivating TOTP
+        """
+
+        user1 = User.objects.create(
+            username="testuser",
+            password="test",
+            first_name="Test",
+            last_name="User",
+        )
+        Authenticator.objects.create(
+            user=user1,
+            type=Authenticator.Type.TOTP,
+            data={},
+        )
+
+        response = self.client.post(
+            f"{self.ENDPOINT}{user1.pk}/deactivate_totp/",
+        )
+
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(Authenticator.objects.filter(user=user1).count(), 0)
+
+        # fail if already deactivated
+        response = self.client.post(
+            f"{self.ENDPOINT}{user1.pk}/deactivate_totp/",
+        )
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+        regular_user = User.objects.create_user(username="regular_user")
+        regular_user.user_permissions.add(
+            *Permission.objects.all(),
+        )
+        self.client.force_authenticate(regular_user)
+        Authenticator.objects.create(
+            user=user1,
+            type=Authenticator.Type.TOTP,
+            data={},
+        )
+
+        response = self.client.post(
+            f"{self.ENDPOINT}{user1.pk}/deactivate_totp/",
+        )
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
 
 class TestApiGroup(DirectoriesMixin, APITestCase):
     ENDPOINT = "/api/groups/"
index eede0d2b0f6e745e598510ab4f65cfb6fa05403e..1075a0af88b34162866e16c2630ae481f465c1f2 100644 (file)
@@ -1,5 +1,6 @@
 from unittest import mock
 
+from allauth.mfa.models import Authenticator
 from allauth.socialaccount.models import SocialAccount
 from allauth.socialaccount.models import SocialApp
 from django.contrib.auth.models import User
@@ -299,3 +300,82 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
             len(self.user.socialaccount_set.filter(pk=social_account_id)),
             0,
         )
+
+
+class TestApiTOTPViews(APITestCase):
+    ENDPOINT = "/api/profile/totp/"
+
+    def setUp(self):
+        super().setUp()
+
+        self.user = User.objects.create_superuser(username="temp_admin")
+        self.client.force_authenticate(user=self.user)
+
+    def test_get_totp(self):
+        """
+        GIVEN:
+            - Existing user account
+        WHEN:
+            - API request is made to TOTP endpoint
+        THEN:
+            - TOTP is generated
+        """
+        response = self.client.get(
+            self.ENDPOINT,
+        )
+
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertIn("qr_svg", response.data)
+        self.assertIn("secret", response.data)
+
+    @mock.patch("allauth.mfa.totp.internal.auth.validate_totp_code")
+    def test_activate_totp(self, mock_validate_totp_code):
+        """
+        GIVEN:
+            - Existing user account
+        WHEN:
+            - API request is made to activate TOTP
+        THEN:
+            - TOTP is activated, recovery codes are returned
+        """
+        mock_validate_totp_code.return_value = True
+
+        response = self.client.post(
+            self.ENDPOINT,
+            data={
+                "secret": "123",
+                "code": "456",
+            },
+        )
+
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertTrue(Authenticator.objects.filter(user=self.user).exists())
+        self.assertIn("recovery_codes", response.data)
+
+    def test_deactivate_totp(self):
+        """
+        GIVEN:
+            - Existing user account with TOTP enabled
+        WHEN:
+            - API request is made to deactivate TOTP
+        THEN:
+            - TOTP is deactivated
+        """
+        Authenticator.objects.create(
+            user=self.user,
+            type=Authenticator.Type.TOTP,
+            data={},
+        )
+
+        response = self.client.delete(
+            self.ENDPOINT,
+        )
+
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(Authenticator.objects.filter(user=self.user).count(), 0)
+
+        # test fails
+        response = self.client.delete(
+            self.ENDPOINT,
+        )
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
index 265682f9130b53024608e2aea6918138f51bfa60..0b7b65ab12b4e12b9b63712be40443a826173f62 100644 (file)
@@ -2,7 +2,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: paperless-ngx\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2024-10-19 22:56-0700\n"
+"POT-Creation-Date: 2024-10-19 23:22-0700\n"
 "PO-Revision-Date: 2022-02-17 04:17\n"
 "Last-Translator: \n"
 "Language-Team: English\n"
@@ -1039,6 +1039,7 @@ msgid "Password"
 msgstr ""
 
 #: documents/templates/account/login.html:30
+#: documents/templates/mfa/authenticate.html:23
 msgid "Sign in"
 msgstr ""
 
@@ -1161,6 +1162,24 @@ msgstr ""
 msgid "Here's a link to the docs."
 msgstr ""
 
+#: documents/templates/mfa/authenticate.html:7
+msgid "Paperless-ngx Two-Factor Authentication"
+msgstr ""
+
+#: documents/templates/mfa/authenticate.html:12
+msgid ""
+"Your account is protected by two-factor authentication. Please enter an "
+"authenticator code:"
+msgstr ""
+
+#: documents/templates/mfa/authenticate.html:17
+msgid "Code"
+msgstr ""
+
+#: documents/templates/mfa/authenticate.html:24
+msgid "Cancel"
+msgstr ""
+
 #: documents/templates/paperless-ngx/base.html:58
 msgid "Share link was not found."
 msgstr ""
@@ -1366,139 +1385,139 @@ msgstr ""
 msgid "paperless application settings"
 msgstr ""
 
-#: paperless/settings.py:684
+#: paperless/settings.py:687
 msgid "English (US)"
 msgstr ""
 
-#: paperless/settings.py:685
+#: paperless/settings.py:688
 msgid "Arabic"
 msgstr ""
 
-#: paperless/settings.py:686
+#: paperless/settings.py:689
 msgid "Afrikaans"
 msgstr ""
 
-#: paperless/settings.py:687
+#: paperless/settings.py:690
 msgid "Belarusian"
 msgstr ""
 
-#: paperless/settings.py:688
+#: paperless/settings.py:691
 msgid "Bulgarian"
 msgstr ""
 
-#: paperless/settings.py:689
+#: paperless/settings.py:692
 msgid "Catalan"
 msgstr ""
 
-#: paperless/settings.py:690
+#: paperless/settings.py:693
 msgid "Czech"
 msgstr ""
 
-#: paperless/settings.py:691
+#: paperless/settings.py:694
 msgid "Danish"
 msgstr ""
 
-#: paperless/settings.py:692
+#: paperless/settings.py:695
 msgid "German"
 msgstr ""
 
-#: paperless/settings.py:693
+#: paperless/settings.py:696
 msgid "Greek"
 msgstr ""
 
-#: paperless/settings.py:694
+#: paperless/settings.py:697
 msgid "English (GB)"
 msgstr ""
 
-#: paperless/settings.py:695
+#: paperless/settings.py:698
 msgid "Spanish"
 msgstr ""
 
-#: paperless/settings.py:696
+#: paperless/settings.py:699
 msgid "Finnish"
 msgstr ""
 
-#: paperless/settings.py:697
+#: paperless/settings.py:700
 msgid "French"
 msgstr ""
 
-#: paperless/settings.py:698
+#: paperless/settings.py:701
 msgid "Hungarian"
 msgstr ""
 
-#: paperless/settings.py:699
+#: paperless/settings.py:702
 msgid "Italian"
 msgstr ""
 
-#: paperless/settings.py:700
+#: paperless/settings.py:703
 msgid "Japanese"
 msgstr ""
 
-#: paperless/settings.py:701
+#: paperless/settings.py:704
 msgid "Korean"
 msgstr ""
 
-#: paperless/settings.py:702
+#: paperless/settings.py:705
 msgid "Luxembourgish"
 msgstr ""
 
-#: paperless/settings.py:703
+#: paperless/settings.py:706
 msgid "Norwegian"
 msgstr ""
 
-#: paperless/settings.py:704
+#: paperless/settings.py:707
 msgid "Dutch"
 msgstr ""
 
-#: paperless/settings.py:705
+#: paperless/settings.py:708
 msgid "Polish"
 msgstr ""
 
-#: paperless/settings.py:706
+#: paperless/settings.py:709
 msgid "Portuguese (Brazil)"
 msgstr ""
 
-#: paperless/settings.py:707
+#: paperless/settings.py:710
 msgid "Portuguese"
 msgstr ""
 
-#: paperless/settings.py:708
+#: paperless/settings.py:711
 msgid "Romanian"
 msgstr ""
 
-#: paperless/settings.py:709
+#: paperless/settings.py:712
 msgid "Russian"
 msgstr ""
 
-#: paperless/settings.py:710
+#: paperless/settings.py:713
 msgid "Slovak"
 msgstr ""
 
-#: paperless/settings.py:711
+#: paperless/settings.py:714
 msgid "Slovenian"
 msgstr ""
 
-#: paperless/settings.py:712
+#: paperless/settings.py:715
 msgid "Serbian"
 msgstr ""
 
-#: paperless/settings.py:713
+#: paperless/settings.py:716
 msgid "Swedish"
 msgstr ""
 
-#: paperless/settings.py:714
+#: paperless/settings.py:717
 msgid "Turkish"
 msgstr ""
 
-#: paperless/settings.py:715
+#: paperless/settings.py:718
 msgid "Ukrainian"
 msgstr ""
 
-#: paperless/settings.py:716
+#: paperless/settings.py:719
 msgid "Chinese Simplified"
 msgstr ""
 
-#: paperless/urls.py:254
+#: paperless/urls.py:268
 msgid "Paperless-ngx administration"
 msgstr ""
 
index 52f9e2b33d1a6f6d4d9503142acc2ac3e2b0b2c0..d5acfe465a86ea24b0fe1ea3732950eeacf7994f 100644 (file)
@@ -1,5 +1,6 @@
 import logging
 
+from allauth.mfa.adapter import get_adapter as get_mfa_adapter
 from allauth.socialaccount.models import SocialAccount
 from django.contrib.auth.models import Group
 from django.contrib.auth.models import Permission
@@ -32,6 +33,11 @@ class UserSerializer(serializers.ModelSerializer):
         required=False,
     )
     inherited_permissions = serializers.SerializerMethodField()
+    is_mfa_enabled = serializers.SerializerMethodField()
+
+    def get_is_mfa_enabled(self, user: User):
+        mfa_adapter = get_mfa_adapter()
+        return mfa_adapter.is_mfa_enabled(user)
 
     class Meta:
         model = User
@@ -49,6 +55,7 @@ class UserSerializer(serializers.ModelSerializer):
             "groups",
             "user_permissions",
             "inherited_permissions",
+            "is_mfa_enabled",
         )
 
     def get_inherited_permissions(self, obj):
@@ -130,6 +137,11 @@ class ProfileSerializer(serializers.ModelSerializer):
         read_only=True,
         source="socialaccount_set",
     )
+    is_mfa_enabled = serializers.SerializerMethodField()
+
+    def get_is_mfa_enabled(self, user: User):
+        mfa_adapter = get_mfa_adapter()
+        return mfa_adapter.is_mfa_enabled(user)
 
     class Meta:
         model = User
@@ -141,6 +153,7 @@ class ProfileSerializer(serializers.ModelSerializer):
             "auth_token",
             "social_accounts",
             "has_usable_password",
+            "is_mfa_enabled",
         )
 
 
index d6489fa8179df948a9bb2af1a195c9d63267a2a2..e5f31800f7693f035b2571b470cfbcd8ba27e151 100644 (file)
@@ -316,6 +316,7 @@ INSTALLED_APPS = [
     "allauth",
     "allauth.account",
     "allauth.socialaccount",
+    "allauth.mfa",
     *env_apps,
 ]
 
@@ -458,6 +459,8 @@ SOCIALACCOUNT_PROVIDERS = json.loads(
     os.getenv("PAPERLESS_SOCIALACCOUNT_PROVIDERS", "{}"),
 )
 
+MFA_TOTP_ISSUER = "Paperless-ngx"
+
 ACCOUNT_EMAIL_SUBJECT_PREFIX = "[Paperless-ngx] "
 
 DISABLE_REGULAR_LOGIN = __get_boolean("PAPERLESS_DISABLE_REGULAR_LOGIN")
index a024c925a7e5c3e50b339b91998a1a976469bc9f..2ebd7e739473076a2e346ddce593219ccb12c0fb 100644 (file)
@@ -1,6 +1,7 @@
 import os
 
 from allauth.account import views as allauth_account_views
+from allauth.mfa.base import views as allauth_mfa_views
 from allauth.socialaccount import views as allauth_social_account_views
 from allauth.urls import build_provider_urlpatterns
 from django.conf import settings
@@ -54,6 +55,7 @@ from paperless.views import GenerateAuthTokenView
 from paperless.views import GroupViewSet
 from paperless.views import ProfileView
 from paperless.views import SocialAccountProvidersView
+from paperless.views import TOTPView
 from paperless.views import UserViewSet
 from paperless_mail.views import MailAccountTestView
 from paperless_mail.views import MailAccountViewSet
@@ -146,19 +148,34 @@ urlpatterns = [
                     BulkEditObjectsView.as_view(),
                     name="bulk_edit_objects",
                 ),
-                path("profile/generate_auth_token/", GenerateAuthTokenView.as_view()),
-                path(
-                    "profile/disconnect_social_account/",
-                    DisconnectSocialAccountView.as_view(),
-                ),
-                path(
-                    "profile/social_account_providers/",
-                    SocialAccountProvidersView.as_view(),
-                ),
                 re_path(
                     "^profile/",
-                    ProfileView.as_view(),
-                    name="profile_view",
+                    include(
+                        [
+                            path(
+                                "generate_auth_token/",
+                                GenerateAuthTokenView.as_view(),
+                            ),
+                            path(
+                                "disconnect_social_account/",
+                                DisconnectSocialAccountView.as_view(),
+                            ),
+                            path(
+                                "social_account_providers/",
+                                SocialAccountProvidersView.as_view(),
+                            ),
+                            re_path(
+                                "^$",
+                                ProfileView.as_view(),
+                                name="profile_view",
+                            ),
+                            path(
+                                "totp/",
+                                TOTPView.as_view(),
+                                name="totp_view",
+                            ),
+                        ],
+                    ),
                 ),
                 re_path(
                     "^status/",
@@ -296,6 +313,12 @@ urlpatterns = [
                     ),
                 ),
                 *build_provider_urlpatterns(),
+                # mfa, see allauth/mfa/base/urls.py
+                path(
+                    "2fa/authenticate/",
+                    allauth_mfa_views.authenticate,
+                    name="mfa_authenticate",
+                ),
             ],
         ),
     ),
index 974830d8300c010b0dcf712a4359281fdbc16491..b5142ed625dc567cd4b7cc7b56eb5fb7c9beda32 100644 (file)
@@ -1,6 +1,12 @@
 import os
 from collections import OrderedDict
 
+from allauth.mfa import signals
+from allauth.mfa.adapter import get_adapter as get_mfa_adapter
+from allauth.mfa.base.internal.flows import delete_and_cleanup
+from allauth.mfa.models import Authenticator
+from allauth.mfa.recovery_codes.internal.flows import auto_generate_recovery_codes
+from allauth.mfa.totp.internal import auth as totp_auth
 from allauth.socialaccount.adapter import get_adapter
 from allauth.socialaccount.models import SocialAccount
 from django.contrib.auth.models import Group
@@ -8,9 +14,12 @@ from django.contrib.auth.models import User
 from django.db.models.functions import Lower
 from django.http import HttpResponse
 from django.http import HttpResponseBadRequest
+from django.http import HttpResponseForbidden
+from django.http import HttpResponseNotFound
 from django.views.generic import View
 from django_filters.rest_framework import DjangoFilterBackend
 from rest_framework.authtoken.models import Token
+from rest_framework.decorators import action
 from rest_framework.filters import OrderingFilter
 from rest_framework.generics import GenericAPIView
 from rest_framework.pagination import PageNumberPagination
@@ -100,6 +109,24 @@ class UserViewSet(ModelViewSet):
     filterset_class = UserFilterSet
     ordering_fields = ("username",)
 
+    @action(detail=True, methods=["post"])
+    def deactivate_totp(self, request, pk=None):
+        request_user = request.user
+        user = User.objects.get(pk=pk)
+        if not request_user.is_superuser and request_user != user:
+            return HttpResponseForbidden(
+                "You do not have permission to deactivate TOTP for this user",
+            )
+        authenticator = Authenticator.objects.filter(
+            user=user,
+            type=Authenticator.Type.TOTP,
+        ).first()
+        if authenticator is not None:
+            delete_and_cleanup(request, authenticator)
+            return Response(True)
+        else:
+            return HttpResponseNotFound("TOTP not found")
+
 
 class GroupViewSet(ModelViewSet):
     model = Group
@@ -145,6 +172,76 @@ class ProfileView(GenericAPIView):
         return Response(serializer.to_representation(user))
 
 
+class TOTPView(GenericAPIView):
+    """
+    TOTP views
+    """
+
+    permission_classes = [IsAuthenticated]
+
+    def get(self, request, *args, **kwargs):
+        """
+        Generates a new TOTP secret and returns the URL and SVG
+        """
+        user = self.request.user
+        mfa_adapter = get_mfa_adapter()
+        secret = totp_auth.get_totp_secret(regenerate=True)
+        url = mfa_adapter.build_totp_url(user, secret)
+        svg = mfa_adapter.build_totp_svg(url)
+        return Response(
+            {
+                "url": url,
+                "qr_svg": svg,
+                "secret": secret,
+            },
+        )
+
+    def post(self, request, *args, **kwargs):
+        """
+        Validates a TOTP code and activates the TOTP authenticator
+        """
+        valid = totp_auth.validate_totp_code(
+            request.data["secret"],
+            request.data["code"],
+        )
+        recovery_codes = None
+        if valid:
+            auth = totp_auth.TOTP.activate(
+                request.user,
+                request.data["secret"],
+            ).instance
+            signals.authenticator_added.send(
+                sender=Authenticator,
+                request=request,
+                user=request.user,
+                authenticator=auth,
+            )
+            rc_auth: Authenticator = auto_generate_recovery_codes(request)
+            if rc_auth:
+                recovery_codes = rc_auth.wrap().get_unused_codes()
+        return Response(
+            {
+                "success": valid,
+                "recovery_codes": recovery_codes,
+            },
+        )
+
+    def delete(self, request, *args, **kwargs):
+        """
+        Deactivates the TOTP authenticator
+        """
+        user = self.request.user
+        authenticator = Authenticator.objects.filter(
+            user=user,
+            type=Authenticator.Type.TOTP,
+        ).first()
+        if authenticator is not None:
+            delete_and_cleanup(request, authenticator)
+            return Response(True)
+        else:
+            return HttpResponseNotFound("TOTP not found")
+
+
 class GenerateAuthTokenView(GenericAPIView):
     """
     Generates (or re-generates) an auth token, requires a logged in user