objective categorical abstract machine language personal data server
65
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add TOTP and email 2FA support

futurGH e627d486 425576b7

+2042 -493
+17
bin/main.ml
··· 31 31 ; (* account ui *) 32 32 (get, "/account", Api.Account_.Index.get_handler) 33 33 ; (post, "/account", Api.Account_.Index.post_handler) 34 + ; (get, "/account/security", Api.Account_.Security.Index.get_handler) 35 + ; (post, "/account/security", Api.Account_.Security.Index.post_handler) 36 + ; ( get 37 + , "/account/security/backup-codes" 38 + , Api.Account_.Security.Backup_codes.count_handler ) 39 + ; ( post 40 + , "/account/security/backup-codes/regenerate" 41 + , Api.Account_.Security.Backup_codes.regenerate_handler ) 42 + ; ( get 43 + , "/account/security/totp/setup" 44 + , Api.Account_.Security.Totp.setup_handler ) 45 + ; ( post 46 + , "/account/security/totp/verify" 47 + , Api.Account_.Security.Totp.verify_handler ) 48 + ; ( post 49 + , "/account/security/totp/disable" 50 + , Api.Account_.Security.Totp.disable_handler ) 34 51 ; (get, "/account/permissions", Api.Account_.Permissions.get_handler) 35 52 ; (post, "/account/permissions", Api.Account_.Permissions.post_handler) 36 53 ; (get, "/account/identity", Api.Account_.Identity.get_handler)
+11
frontend/client/QRCode.mlx
··· 1 + module SVG = struct 2 + external make : 3 + value:string 4 + -> ?size:int 5 + -> ?title:string 6 + -> ?bgColor:string 7 + -> ?fgColor:string 8 + -> React.element = "QRCodeSVG" 9 + [@@mel.module "qrcode.react"] [@@react.component] 10 + end 11 + [@@platform js]
+1
frontend/client/Router.mlx
··· 19 19 ; {path= "/account/signup"; template= (module SignupPage)} 20 20 ; {path= "/account/migrate"; template= (module MigratePage)} 21 21 ; {path= "/account"; template= (module AccountPage)} 22 + ; {path= "/account/security"; template= (module AccountSecurityPage)} 22 23 ; {path= "/account/permissions"; template= (module AccountPermissionsPage)} 23 24 ; {path= "/account/identity"; template= (module AccountIdentityPage)} 24 25 ; {path= "/admin/login"; template= (module AdminLoginPage)}
+7 -3
frontend/src/components/AccountSidebar.mlx
··· 6 6 7 7 let pages = 8 8 [ ("Account", "/account") 9 - ; ("Permissions", "/account/permissions") 10 - ; ("Identity", "/account/identity") ] 9 + ; ("Security", "/account/security") 10 + ; ("Identity", "/account/identity") 11 + ; ("Permissions", "/account/permissions") ] 11 12 12 13 let[@react.component] make ~current_user ~logged_in_users ~active_page () = 13 14 <Sidebar ··· 19 20 footer=(<a 20 21 href=( "/account/logout?did=" 21 22 ^ Js.Global.encodeURIComponent current_user.did )> 22 - <Button kind=`Secondary className="mt-2 justify-start pl-0 active:shadow-none focus-visible:shadow-none"> 23 + <Button 24 + kind=`Secondary 25 + className="mt-2 justify-start pl-0 active:shadow-none \ 26 + focus-visible:shadow-none"> 23 27 (string "log out") 24 28 </Button> 25 29 </a>)
+5 -3
frontend/src/components/Input.mlx
··· 4 4 let req_marker = " *" 5 5 6 6 let[@react.component] make ?id ~name ?(className = "") ?(type_ = "text") ?label 7 - ?(sr_only = false) ?value ?defaultValue ?placeholder ?autoComplete 8 - ?(required = false) ?(disabled = false) ?trailing ?(showIndicator = true) 9 - ?onChange () = 7 + ?(sr_only = false) ?value ?defaultValue ?placeholder ?autoComplete ?pattern 8 + ?inputMode ?(required = false) ?(disabled = false) ?trailing 9 + ?(showIndicator = true) ?onChange () = 10 10 let id = Option.value id ~default:name in 11 11 let placeholder = if label <> None && sr_only then label else placeholder in 12 12 let input = ··· 16 16 name 17 17 ?placeholder 18 18 ?autoComplete 19 + ?pattern 20 + ?inputMode 19 21 required 20 22 disabled 21 23 ?value
+2 -212
frontend/src/templates/AccountPage.mlx
··· 7 7 {did: string; handle: string; avatar_data_uri: string option [@default None]} 8 8 [@@deriving json] 9 9 10 - type passkey_display = 11 - { id: int 12 - ; name: string 13 - ; created_at: int 14 - ; last_used_at: int option [@default None] } 15 - [@@deriving json] 16 - 17 10 type props = 18 11 { current_user: actor 19 12 ; logged_in_users: actor list ··· 30 23 ; delete_pending: bool [@default false] 31 24 ; error: string option [@default None] 32 25 ; success: string option [@default None] 33 - ; delete_error: string option [@default None] 34 - ; passkeys: passkey_display list [@default []] } 26 + ; delete_error: string option [@default None] } 35 27 [@@deriving json] 36 28 37 29 let[@react.component] make ··· 51 43 ; delete_pending 52 44 ; error 53 45 ; success 54 - ; delete_error 55 - ; passkeys } : 46 + ; delete_error } : 56 47 props ) () = 57 48 let emailModalOpen, setEmailModalOpen = 58 49 useState (fun () -> email_change_pending) ··· 94 85 let importLoading, setImportLoading = useState (fun () -> false) in 95 86 let importError, setImportError = useState (fun () -> None) in 96 87 let importSuccess, setImportSuccess = useState (fun () -> false) in 97 - let passkeysState, setPasskeysState = useState (fun () -> passkeys) in 98 - let addingPasskey, setAddingPasskey = useState (fun () -> false) in 99 - let passkeyName, setPasskeyName = useState (fun () -> "") in 100 - let passkeyError, setPasskeyError = useState (fun () -> None) in 101 - let webauthnSupported, setWebauthnSupported = useState (fun () -> false) in 102 - let currentWebAuthnOptions = useRef None in 103 88 let fileInputRef : Dom.element Js.nullable React.ref = 104 89 useRef Js.Nullable.null 105 - in 106 - let _ = 107 - React.useEffect0 (fun () -> 108 - setWebauthnSupported (fun _ -> WebAuthn.browserSupportsWebAuthn ()) ; 109 - None ) 110 90 in 111 91 <div 112 92 className="w-full h-full max-w-[816px] px-8 pt-16 mx-auto flex flex-col \ ··· 535 515 showIndicator=false 536 516 placeholder=handle 537 517 /> 538 - <Input 539 - name="password" 540 - type_="password" 541 - label="Password" 542 - placeholder="********" 543 - autoComplete="current-password" 544 - showIndicator=false 545 - /> 546 518 ( match error with 547 519 | Some error -> 548 520 <span ··· 682 654 overwrite any existing data with the contents of the \ 683 655 backup." ) 684 656 </p> ) 685 - </section> 686 - <section className="mt-8"> 687 - <h2 className="text-xl font-serif text-mana-200 mb-1"> 688 - (string "passkeys") 689 - </h2> 690 - <p className="text-mist-100 mb-4"> 691 - (string 692 - "Passkeys are stored on your device, providing a more \ 693 - convenient alternative to logging in with a password." ) 694 - </p> 695 - <ClientOnly 696 - fallback=(<p className="text-mist-80 text-sm"> 697 - (string "Loading passkeys...") 698 - </p>)> 699 - [%browser_only 700 - fun () -> 701 - let formatDate ts = 702 - let d = Js.Date.fromFloat (Float.of_int ts) in 703 - Js.Date.toLocaleDateString d 704 - in 705 - let addPasskey () = 706 - setAddingPasskey (fun _ -> true) ; 707 - setPasskeyError (fun _ -> None) ; 708 - let _ = 709 - Fetch.fetch "/account/passkeys/register/options" 710 - |> Js.Promise.then_ (fun response -> 711 - if Fetch.Response.ok response then 712 - Fetch.Response.json response 713 - else Js.Exn.raiseError "Failed to get options" ) 714 - |> Js.Promise.then_ (fun options -> 715 - currentWebAuthnOptions.current <- Some options ; 716 - WebAuthn.startRegistration {optionsJSON= options} ) 717 - |> Js.Promise.then_ (fun credential -> 718 - let challenge = 719 - Js.Dict.unsafeGet 720 - (Obj.magic currentWebAuthnOptions.current) 721 - "challenge" 722 - in 723 - let body = 724 - Js.Json.object_ 725 - (Js.Dict.fromArray 726 - [| ( "response" 727 - , Js.Json.string 728 - (Js.Json.stringify credential) ) 729 - ; ("challenge", challenge) 730 - ; ( "name" 731 - , Js.Json.string 732 - ( if passkeyName = "" then "Passkey" 733 - else passkeyName ) ) |] ) 734 - in 735 - Fetch.fetchWithInit 736 - "/account/passkeys/register/verify" 737 - (Fetch.RequestInit.make ~method_:Post 738 - ~body: 739 - (Fetch.BodyInit.make (Js.Json.stringify body)) 740 - ~headers: 741 - (Fetch.HeadersInit.makeWithArray 742 - [|("Content-Type", "application/json")|] ) 743 - () ) ) 744 - |> Js.Promise.then_ (fun response -> 745 - setAddingPasskey (fun _ -> false) ; 746 - if Fetch.Response.ok response then begin 747 - setPasskeyName (fun _ -> "") ; 748 - Fetch.fetch "/account/passkeys" 749 - |> Js.Promise.then_ (fun r -> 750 - Fetch.Response.json r ) 751 - |> Js.Promise.then_ (fun json -> 752 - let pks : passkey_display list = 753 - Js.Dict.unsafeGet (Obj.magic json) 754 - "passkeys" 755 - |> Obj.magic |> Array.to_list 756 - in 757 - setPasskeysState (fun _ -> pks) ; 758 - Js.Promise.resolve () ) 759 - end 760 - else begin 761 - setPasskeyError (fun _ -> 762 - Some "Failed to register passkey" ) ; 763 - Fetch.Response.text response 764 - |> Js.Promise.then_ (fun e -> 765 - Js.Console.error e ; Js.Promise.resolve () ) 766 - end ) 767 - |> Js.Promise.catch (fun e -> 768 - Js.Console.error (Obj.magic e) ; 769 - setAddingPasskey (fun _ -> false) ; 770 - setPasskeyError (fun _ -> 771 - Some "Passkey registration cancelled or failed" ) ; 772 - Js.Promise.resolve () ) 773 - in 774 - () 775 - in 776 - let deletePasskey id = 777 - let _ = 778 - Fetch.fetchWithInit 779 - ("/account/passkeys/" ^ string_of_int id) 780 - (Fetch.RequestInit.make ~method_:Delete ()) 781 - |> Js.Promise.then_ (fun response -> 782 - if Fetch.Response.ok response then 783 - setPasskeysState (fun ps -> 784 - List.filter (fun p -> p.id <> id) ps ) ; 785 - Js.Promise.resolve () ) 786 - in 787 - () 788 - in 789 - if not webauthnSupported then 790 - <p className="text-mist-80 text-sm"> 791 - (string "Your browser doesn't support passkeys.") 792 - </p> 793 - else 794 - <div> 795 - ( if List.length passkeysState = 0 then 796 - <p className="text-mist-80 text-sm mb-4"> 797 - (string "You haven't added any passkeys yet.") 798 - </p> 799 - else 800 - <ul className="mb-4 space-y-2"> 801 - ( List.map 802 - (fun (pk : passkey_display) -> 803 - <li 804 - key=(string_of_int pk.id) 805 - className="flex items-center \ 806 - justify-between p-3 border \ 807 - border-mist-60 rounded-lg"> 808 - <div> 809 - <span 810 - className="font-medium text-mist-100"> 811 - (string pk.name) 812 - </span> 813 - <span 814 - className="text-sm text-mist-80 ml-2"> 815 - (string 816 - ( {js|⸱ |js} 817 - ^ formatDate pk.created_at ) ) 818 - </span> 819 - </div> 820 - <button 821 - type_="button" 822 - className="p-1 text-phoenix-100 \ 823 - hover:text-phoenix-200 \ 824 - cursor-pointer" 825 - onClick=(fun _ -> deletePasskey pk.id)> 826 - <TrashIcon className="w-4 h-4" /> 827 - </button> 828 - </li> ) 829 - passkeysState 830 - |> Array.of_list |> React.array ) 831 - </ul> ) 832 - <div className="flex flex-row gap-x-3"> 833 - <div className="flex-1"> 834 - <Input 835 - name="passkey_name" 836 - label="Passkey name" 837 - placeholder="My Little Passkey" 838 - showIndicator=false 839 - value=passkeyName 840 - onChange=(fun e -> 841 - setPasskeyName (fun _ -> 842 - (Event.Form.target e)##value ) ) 843 - /> 844 - </div> 845 - <div className="self-end"> 846 - <Button 847 - type_="button" 848 - disabled=addingPasskey 849 - onClick=(fun _ -> addPasskey ())> 850 - (string 851 - (if addingPasskey then "adding..." else "add") ) 852 - </Button> 853 - </div> 854 - </div> 855 - ( match passkeyError with 856 - | Some err -> 857 - <span 858 - className="inline-flex items-center \ 859 - text-phoenix-100 text-sm mt-2"> 860 - <CircleAlertIcon className="w-4 h-4 mr-2" /> 861 - (string err) 862 - </span> 863 - | None -> 864 - null ) 865 - </div>] 866 - </ClientOnly> 867 657 </section> 868 658 <section className="mt-8"> 869 659 <h2 className="text-xl font-serif text-mana-200 mb-1">
+719
frontend/src/templates/AccountSecurityPage.mlx
··· 1 + [@@@ocaml.warning "-26-27"] 2 + 3 + open Melange_json.Primitives 4 + open React 5 + 6 + type actor = AccountSwitcher.actor = 7 + {did: string; handle: string; avatar_data_uri: string option [@default None]} 8 + [@@deriving json] 9 + 10 + type passkey_display = 11 + { id: int 12 + ; name: string 13 + ; created_at: int 14 + ; last_used_at: int option [@default None] } 15 + [@@deriving json] 16 + 17 + type props = 18 + { current_user: actor 19 + ; logged_in_users: actor list 20 + ; csrf_token: string 21 + ; passkeys: passkey_display list [@default []] 22 + ; totp_enabled: bool [@default false] 23 + ; email_2fa_enabled: bool [@default false] 24 + ; backup_codes_remaining: int [@default 10] 25 + ; error: string option [@default None] 26 + ; success: string option [@default None] } 27 + [@@deriving json] 28 + 29 + let[@react.component] make 30 + ~props: 31 + ({ current_user 32 + ; logged_in_users 33 + ; csrf_token 34 + ; passkeys 35 + ; totp_enabled 36 + ; email_2fa_enabled 37 + ; backup_codes_remaining 38 + ; error 39 + ; success } : 40 + props ) () = 41 + (* passkeys state *) 42 + let passkeysState, setPasskeysState = useState (fun () -> passkeys) in 43 + let addingPasskey, setAddingPasskey = useState (fun () -> false) in 44 + let passkeyName, setPasskeyName = useState (fun () -> "") in 45 + let passkeyError, setPasskeyError = useState (fun () -> None) in 46 + let webauthnSupported, setWebauthnSupported = useState (fun () -> false) in 47 + let currentWebAuthnOptions = useRef None in 48 + (* TOTP state *) 49 + let totpEnabled, setTotpEnabled = useState (fun () -> totp_enabled) in 50 + let settingUpTotp, setSettingUpTotp = useState (fun () -> false) in 51 + let totpSecret, setTotpSecret = useState (fun () -> "") in 52 + let totpUri, setTotpUri = useState (fun () -> "") in 53 + let totpCode, setTotpCode = useState (fun () -> "") in 54 + let totpError, setTotpError = useState (fun () -> None) in 55 + let totpLoading, setTotpLoading = useState (fun () -> false) in 56 + let backupCodes, setBackupCodes = 57 + useState (fun () -> ([||] : string array)) 58 + in 59 + let showBackupCodes, setShowBackupCodes = useState (fun () -> false) in 60 + (* email 2FA state *) 61 + let email2faEnabled, setEmail2faEnabled = 62 + useState (fun () -> email_2fa_enabled) 63 + in 64 + let email2faLoading, setEmail2faLoading = useState (fun () -> false) in 65 + (* backup codes state *) 66 + let backupCodesRemaining, setBackupCodesRemaining = 67 + useState (fun () -> backup_codes_remaining) 68 + in 69 + let regeneratingCodes, setRegeneratingCodes = useState (fun () -> false) in 70 + (* success/error state *) 71 + let successMessage, setSuccessMessage = useState (fun () -> success) in 72 + let errorMessage, setErrorMessage = useState (fun () -> error) in 73 + let _ = 74 + React.useEffect0 (fun () -> 75 + setWebauthnSupported (fun _ -> WebAuthn.browserSupportsWebAuthn ()) ; 76 + None ) 77 + in 78 + <div 79 + className="w-full h-full max-w-[816px] px-8 pt-16 mx-auto flex flex-col \ 80 + md:flex-row gap-8"> 81 + <AccountSidebar current_user logged_in_users active_page="/account/security" 82 + /> 83 + <main className="flex-1 w-full md:max-w-lg"> 84 + <h1 className="text-2xl font-serif text-mana-200 mb-1"> 85 + (string "security") 86 + </h1> 87 + <p className="text-mist-100"> 88 + (string "Manage your authentication methods and security settings.") 89 + </p> 90 + ( match errorMessage with 91 + | Some err -> 92 + <span 93 + className="inline-flex items-center text-phoenix-100 text-sm my-2"> 94 + <CircleAlertIcon className="w-4 h-4 mr-2" /> (string err) 95 + </span> 96 + | None -> 97 + null ) 98 + ( match successMessage with 99 + | Some err -> 100 + <span className="inline-flex items-center text-mana-100 text-sm my-2"> 101 + (string err) 102 + </span> 103 + | None -> 104 + null ) 105 + <section> 106 + <h2 className="text-xl font-serif text-mana-200 mb-1 mt-2"> 107 + (string "password") 108 + </h2> 109 + <p className="text-mist-100 mb-4"> 110 + (string "Change your account password.") 111 + </p> 112 + <form className="flex flex-col gap-y-2"> 113 + <input type_="hidden" name="dream.csrf" value=csrf_token /> 114 + <input type_="hidden" name="action" value="change_password" /> 115 + <Input 116 + name="current_password" 117 + type_="password" 118 + label="Current password" 119 + autoComplete="current-password" 120 + showIndicator=false 121 + /> 122 + <Input 123 + name="new_password" 124 + type_="password" 125 + label="New password" 126 + autoComplete="new-password" 127 + showIndicator=false 128 + /> 129 + <Input 130 + name="confirm_password" 131 + type_="password" 132 + label="Confirm new password" 133 + autoComplete="new-password" 134 + showIndicator=false 135 + /> 136 + <Button type_="submit" formMethod="post" className="mt-2"> 137 + (string "change password") 138 + </Button> 139 + </form> 140 + </section> 141 + <section className="mt-8"> 142 + <h2 className="text-xl font-serif text-mana-200 mb-1"> 143 + (string "passkeys") 144 + </h2> 145 + <p className="text-mist-100 mb-4"> 146 + (string 147 + "Passkeys provide a more convenient alternative to a password for \ 148 + logging into your account dashboard." ) 149 + </p> 150 + <ClientOnly 151 + fallback=(array 152 + [| <p className="text-mist-80 text-sm mb-4"> 153 + (string "Loading passkeys...") 154 + </p> 155 + ; <div className="flex flex-row gap-x-3"> 156 + <div className="flex-1"> 157 + <Input 158 + name="passkey_name" 159 + label="Passkey name" 160 + placeholder="My Little Passkey" 161 + showIndicator=false 162 + /> 163 + </div> 164 + <div className="self-end min-w-40"> 165 + <Button type_="button">(string "add")</Button> 166 + </div> 167 + </div> |] )> 168 + [%browser_only 169 + fun () -> 170 + let formatDate ts = 171 + let d = Js.Date.fromFloat (Float.of_int ts) in 172 + Js.Date.toLocaleDateString d 173 + in 174 + let addPasskey () = 175 + setAddingPasskey (fun _ -> true) ; 176 + setPasskeyError (fun _ -> None) ; 177 + let _ = 178 + Fetch.fetch "/account/passkeys/register/options" 179 + |> Js.Promise.then_ (fun response -> 180 + if Fetch.Response.ok response then 181 + Fetch.Response.json response 182 + else Js.Exn.raiseError "Failed to get options" ) 183 + |> Js.Promise.then_ (fun options -> 184 + currentWebAuthnOptions.current <- Some options ; 185 + WebAuthn.startRegistration {optionsJSON= options} ) 186 + |> Js.Promise.then_ (fun credential -> 187 + let challenge = 188 + Js.Dict.unsafeGet 189 + (Obj.magic currentWebAuthnOptions.current) 190 + "challenge" 191 + in 192 + let body = 193 + Js.Json.object_ 194 + (Js.Dict.fromArray 195 + [| ( "response" 196 + , Js.Json.string (Js.Json.stringify credential) 197 + ) 198 + ; ("challenge", challenge) 199 + ; ( "name" 200 + , Js.Json.string 201 + ( if passkeyName = "" then "Passkey" 202 + else passkeyName ) ) |] ) 203 + in 204 + Fetch.fetchWithInit "/account/passkeys/register/verify" 205 + (Fetch.RequestInit.make ~method_:Post 206 + ~body:(Fetch.BodyInit.make (Js.Json.stringify body)) 207 + ~headers: 208 + (Fetch.HeadersInit.makeWithArray 209 + [|("Content-Type", "application/json")|] ) 210 + () ) ) 211 + |> Js.Promise.then_ (fun response -> 212 + setAddingPasskey (fun _ -> false) ; 213 + if Fetch.Response.ok response then begin 214 + setPasskeyName (fun _ -> "") ; 215 + Fetch.fetch "/account/passkeys" 216 + |> Js.Promise.then_ (fun r -> Fetch.Response.json r) 217 + |> Js.Promise.then_ (fun json -> 218 + let pks : passkey_display list = 219 + Js.Dict.unsafeGet (Obj.magic json) "passkeys" 220 + |> Obj.magic |> Array.to_list 221 + in 222 + setPasskeysState (fun _ -> pks) ; 223 + Js.Promise.resolve () ) 224 + end 225 + else begin 226 + setPasskeyError (fun _ -> 227 + Some "Failed to register passkey" ) ; 228 + Js.Promise.resolve () 229 + end ) 230 + |> Js.Promise.catch (fun _ -> 231 + setAddingPasskey (fun _ -> false) ; 232 + setPasskeyError (fun _ -> 233 + Some "Passkey registration cancelled or failed" ) ; 234 + Js.Promise.resolve () ) 235 + in 236 + () 237 + in 238 + let deletePasskey id = 239 + let _ = 240 + Fetch.fetchWithInit 241 + ("/account/passkeys/" ^ string_of_int id) 242 + (Fetch.RequestInit.make ~method_:Delete ()) 243 + |> Js.Promise.then_ (fun response -> 244 + if Fetch.Response.ok response then 245 + setPasskeysState (fun ps -> 246 + List.filter 247 + (fun p -> (p : passkey_display).id <> id) 248 + ps ) ; 249 + Js.Promise.resolve () ) 250 + in 251 + () 252 + in 253 + if not webauthnSupported then 254 + <p className="text-mist-80 text-sm"> 255 + (string "Your browser doesn't support passkeys.") 256 + </p> 257 + else 258 + <div> 259 + ( if List.length passkeysState = 0 then 260 + <p className="text-mist-80 text-sm mb-4"> 261 + (string "You haven't added any passkeys yet.") 262 + </p> 263 + else 264 + <ul className="mb-4 space-y-2"> 265 + ( List.map 266 + (fun (pk : passkey_display) -> 267 + <li 268 + key=(string_of_int pk.id) 269 + className="flex items-center p-3 outline-1 \ 270 + outline-mana-40/50 rounded-lg"> 271 + <span 272 + className="font-medium text-mist-100 truncate"> 273 + (string pk.name) 274 + </span> 275 + <span 276 + className="text-sm text-mist-80 ml-2 \ 277 + min-w-fit"> 278 + (string 279 + ({js|⸱ |js} ^ formatDate pk.created_at) ) 280 + </span> 281 + <button 282 + type_="button" 283 + className="p-1 ml-auto text-phoenix-100 \ 284 + hover:text-phoenix-200 \ 285 + cursor-pointer" 286 + onClick=(fun _ -> deletePasskey pk.id)> 287 + <TrashIcon className="w-4 h-4" /> 288 + </button> 289 + </li> ) 290 + passkeysState 291 + |> Array.of_list |> React.array ) 292 + </ul> ) 293 + <div className="flex flex-row gap-x-3"> 294 + <div className="flex-1"> 295 + <Input 296 + name="passkey_name" 297 + label="Passkey name" 298 + placeholder="My Little Passkey" 299 + showIndicator=false 300 + value=passkeyName 301 + onChange=(fun e -> 302 + setPasskeyName (fun _ -> 303 + (Event.Form.target e)##value ) ) 304 + /> 305 + </div> 306 + <div className="self-end min-w-40"> 307 + <Button 308 + type_="button" 309 + disabled=addingPasskey 310 + onClick=(fun _ -> addPasskey ())> 311 + (string (if addingPasskey then "adding..." else "add")) 312 + </Button> 313 + </div> 314 + </div> 315 + ( match passkeyError with 316 + | Some err -> 317 + <span 318 + className="inline-flex items-center text-phoenix-100 \ 319 + text-sm mt-2"> 320 + <CircleAlertIcon className="w-4 h-4 mr-2" /> 321 + (string err) 322 + </span> 323 + | None -> 324 + null ) 325 + </div>] 326 + </ClientOnly> 327 + </section> 328 + <section className="mt-8"> 329 + <h2 className="text-xl font-serif text-mana-200 mb-1"> 330 + (string "two-factor authentication") 331 + </h2> 332 + <p className="text-mist-100 mb-6"> 333 + (string "Add an extra layer of security to your account.") 334 + </p> 335 + <div className="mb-6"> 336 + <h3 className="text-lg font-medium text-mana-200 mb-2"> 337 + (string "email verification") 338 + </h3> 339 + <p className="text-mist-100 text-sm mb-4"> 340 + (string 341 + "Receive a verification code via email when signing in. Note \ 342 + that this will have no effect if you have an authenticator app \ 343 + set up." ) 344 + </p> 345 + <form className="w-full sm:w-1/2"> 346 + <input type_="hidden" name="dream.csrf" value=csrf_token /> 347 + <input 348 + type_="hidden" 349 + name="action" 350 + value=( if email2faEnabled then "disable_email_2fa" 351 + else "enable_email_2fa" ) 352 + /> 353 + <Button 354 + kind=(if email2faEnabled then `Danger else `Primary) 355 + formMethod="post" 356 + type_="submit"> 357 + (string (if email2faEnabled then "disable" else "enable")) 358 + </Button> 359 + </form> 360 + </div> 361 + <div className="mb-6"> 362 + <h3 className="text-lg font-medium text-mana-200 mb-2"> 363 + (string "authenticator app") 364 + </h3> 365 + <p className="text-mist-100 text-sm mb-4"> 366 + (string "Use an authenticator app to generate verification codes.") 367 + </p> 368 + <ClientOnly 369 + fallback=(<div className="w-full sm:w-1/2"> 370 + ( if totpEnabled then 371 + <Button kind=`Danger type_="button"> 372 + (string "disable") 373 + </Button> 374 + else <Button type_="button">(string "set up")</Button> 375 + ) 376 + </div>)> 377 + [%browser_only 378 + fun () -> 379 + let module Aria = ReactAria in 380 + let startTotpSetup () = 381 + setTotpLoading (fun _ -> true) ; 382 + setTotpError (fun _ -> None) ; 383 + let _ = 384 + Fetch.fetch "/account/security/totp/setup" 385 + |> Js.Promise.then_ (fun response -> 386 + if Fetch.Response.ok response then 387 + Fetch.Response.json response 388 + else begin 389 + setTotpError (fun _ -> 390 + Some "Failed to start TOTP setup" ) ; 391 + Js.Exn.raiseError "Failed" 392 + end ) 393 + |> Js.Promise.then_ (fun json -> 394 + let secret = 395 + Js.Dict.unsafeGet (Obj.magic json) "secret" 396 + |> Js.Json.decodeString |> Option.value ~default:"" 397 + in 398 + let uri = 399 + Js.Dict.unsafeGet (Obj.magic json) "uri" 400 + |> Js.Json.decodeString |> Option.value ~default:"" 401 + in 402 + setTotpSecret (fun _ -> secret) ; 403 + setTotpUri (fun _ -> uri) ; 404 + setSettingUpTotp (fun _ -> true) ; 405 + setTotpLoading (fun _ -> false) ; 406 + Js.Promise.resolve () ) 407 + |> Js.Promise.catch (fun _ -> 408 + setTotpLoading (fun _ -> false) ; 409 + Js.Promise.resolve () ) 410 + in 411 + () 412 + in 413 + let verifyTotp () = 414 + setTotpLoading (fun _ -> true) ; 415 + setTotpError (fun _ -> None) ; 416 + let body = 417 + Js.Json.object_ 418 + (Js.Dict.fromArray [|("code", Js.Json.string totpCode)|]) 419 + in 420 + let _ = 421 + Fetch.fetchWithInit "/account/security/totp/verify" 422 + (Fetch.RequestInit.make ~method_:Post 423 + ~body:(Fetch.BodyInit.make (Js.Json.stringify body)) 424 + ~headers: 425 + (Fetch.HeadersInit.makeWithArray 426 + [|("Content-Type", "application/json")|] ) 427 + () ) 428 + |> Js.Promise.then_ (fun response -> 429 + setTotpLoading (fun _ -> false) ; 430 + if Fetch.Response.ok response then begin 431 + Fetch.Response.json response 432 + |> Js.Promise.then_ (fun json -> 433 + let codes = 434 + Js.Dict.get (Obj.magic json) "backup_codes" 435 + |> Option.map (fun c -> 436 + Obj.magic c |> Js.Json.decodeArray 437 + |> Option.value ~default:[||] 438 + |> Array.map (fun s -> 439 + Js.Json.decodeString s 440 + |> Option.value ~default:"" ) ) 441 + |> Option.value ~default:[||] 442 + in 443 + if Array.length codes > 0 then begin 444 + setBackupCodes (fun _ -> codes) ; 445 + setShowBackupCodes (fun _ -> true) 446 + end ; 447 + setTotpEnabled (fun _ -> true) ; 448 + setSettingUpTotp (fun _ -> false) ; 449 + setTotpCode (fun _ -> "") ; 450 + setBackupCodesRemaining (fun _ -> 451 + Array.length codes ) ; 452 + Js.Promise.resolve () ) 453 + end 454 + else begin 455 + setTotpError (fun _ -> Some "Invalid code") ; 456 + Js.Promise.resolve () 457 + end ) 458 + |> Js.Promise.catch (fun _ -> 459 + setTotpLoading (fun _ -> false) ; 460 + setTotpError (fun _ -> 461 + Some "An error occurred. Please try again." ) ; 462 + Js.Promise.resolve () ) 463 + in 464 + () 465 + in 466 + let disableTotp () = 467 + setTotpLoading (fun _ -> true) ; 468 + let _ = 469 + Fetch.fetchWithInit "/account/security/totp/disable" 470 + (Fetch.RequestInit.make ~method_:Post 471 + ~headers: 472 + (Fetch.HeadersInit.makeWithArray 473 + [|("Content-Type", "application/json")|] ) 474 + () ) 475 + |> Js.Promise.then_ (fun response -> 476 + setTotpLoading (fun _ -> false) ; 477 + if Fetch.Response.ok response then 478 + setTotpEnabled (fun _ -> false) ; 479 + Js.Promise.resolve () ) 480 + |> Js.Promise.catch (fun _ -> 481 + setTotpLoading (fun _ -> false) ; 482 + Js.Promise.resolve () ) 483 + in 484 + () 485 + in 486 + <div className="w-full sm:w-1/2"> 487 + ( if totpEnabled then 488 + <Button 489 + kind=`Danger 490 + onClick=(fun _ -> disableTotp ()) 491 + type_="button"> 492 + (string "disable") 493 + </Button> 494 + else 495 + <Button 496 + onClick=(fun _ -> startTotpSetup ()) type_="button"> 497 + (string "set up") 498 + </Button> ) 499 + <Aria.DialogTrigger 500 + isOpen=settingUpTotp 501 + onOpenChange=(fun o -> 502 + if not o then begin 503 + setSettingUpTotp (fun _ -> false) ; 504 + setTotpCode (fun _ -> "") ; 505 + setTotpError (fun _ -> None) 506 + end )> 507 + <Aria.ModalOverlay 508 + className="fixed inset-0 z-50 bg-mist-80/80 flex \ 509 + items-center justify-center" 510 + isDismissable=true> 511 + <Aria.Modal 512 + className="bg-feather-100 border border-mist-60 \ 513 + rounded-xl px-6 pb-6 pt-5 w-full max-w-sm \ 514 + mx-4 shadow-xl"> 515 + <Aria.Dialog className="outline-none"> 516 + <Aria.Heading 517 + slot="title" 518 + className="text-lg font-serif text-mana-200 mb-2"> 519 + (string "set up authenticator") 520 + </Aria.Heading> 521 + <div className="flex flex-col gap-y-3"> 522 + <p className="text-mist-100 text-sm"> 523 + (string 524 + "Scan this QR code with your authenticator \ 525 + app, or enter the secret manually, then \ 526 + enter the generated one-time password:" ) 527 + </p> 528 + <div className="flex justify-center p-4 rounded-lg"> 529 + <QRCode.SVG 530 + value=totpUri 531 + size=200 532 + title="Authenticator QR code" 533 + bgColor="#c8cfd2" (* feather-100 *) 534 + fgColor="#312b4d" (* mana-200 *) 535 + /> 536 + </div> 537 + <div 538 + className="p-3 bg-mist-20 rounded-lg text-center \ 539 + font-mono text-mana-200 text-sm \ 540 + break-all"> 541 + (string totpSecret) 542 + </div> 543 + <Input 544 + name="totp_code" 545 + label="One-time password" 546 + placeholder="123456" 547 + showIndicator=false 548 + inputMode="numeric" 549 + value=totpCode 550 + onChange=(fun e -> 551 + setTotpCode (fun _ -> 552 + (Event.Form.target e)##value ) ) 553 + /> 554 + ( match totpError with 555 + | Some err -> 556 + <span 557 + className="inline-flex items-center \ 558 + text-phoenix-100 text-sm"> 559 + <CircleAlertIcon className="w-4 h-4 mr-2" /> 560 + (string err) 561 + </span> 562 + | None -> 563 + null ) 564 + <div className="flex flex-row gap-x-3 mt-2"> 565 + <Button 566 + type_="button" 567 + disabled=totpLoading 568 + onClick=(fun _ -> verifyTotp ())> 569 + (string 570 + ( if totpLoading then "verifying..." 571 + else "verify" ) ) 572 + </Button> 573 + <Button 574 + kind=`Tertiary 575 + type_="button" 576 + onClick=(fun _ -> 577 + setSettingUpTotp (fun _ -> false) )> 578 + (string "cancel") 579 + </Button> 580 + </div> 581 + </div> 582 + </Aria.Dialog> 583 + </Aria.Modal> 584 + </Aria.ModalOverlay> 585 + </Aria.DialogTrigger> 586 + <Aria.DialogTrigger 587 + isOpen=showBackupCodes 588 + onOpenChange=(fun o -> 589 + if not o then 590 + setShowBackupCodes (fun _ -> false) )> 591 + <Aria.ModalOverlay 592 + className="fixed inset-0 z-50 bg-mist-80/80 flex \ 593 + items-center justify-center" 594 + isDismissable=true> 595 + <Aria.Modal 596 + className="bg-feather-100 border border-mist-60 \ 597 + rounded-xl px-6 pb-6 pt-5 w-full max-w-sm \ 598 + mx-4 shadow-xl"> 599 + <Aria.Dialog className="outline-none"> 600 + <Aria.Heading 601 + slot="title" 602 + className="text-lg font-serif text-mana-200 mb-2"> 603 + (string "backup codes") 604 + </Aria.Heading> 605 + <div className="flex flex-col gap-y-3"> 606 + <p className="text-mist-100 text-sm"> 607 + (string 608 + "Save these backup codes in a secure \ 609 + location. You can use them to access your \ 610 + account if you lose your authenticator \ 611 + device." ) 612 + </p> 613 + <div 614 + className="grid grid-cols-2 gap-2 rounded-lg \ 615 + font-mono text-sm"> 616 + ( Array.map 617 + (fun code -> 618 + <div 619 + key=code 620 + className="text-center p-2 text-mana-200 \ 621 + bg-mist-20 rounded-sm"> 622 + (string code) 623 + </div> ) 624 + backupCodes 625 + |> React.array ) 626 + </div> 627 + <Button 628 + type_="button" 629 + onClick=(fun _ -> 630 + setShowBackupCodes (fun _ -> false) )> 631 + (string "noted!") 632 + </Button> 633 + </div> 634 + </Aria.Dialog> 635 + </Aria.Modal> 636 + </Aria.ModalOverlay> 637 + </Aria.DialogTrigger> 638 + </div>] 639 + </ClientOnly> 640 + </div> 641 + ( if totpEnabled then 642 + <div> 643 + <h3 className="text-lg font-medium text-mana-200 mb-2"> 644 + (string "backup codes") 645 + </h3> 646 + <p className="text-mist-100 text-sm mb-2"> 647 + (string 648 + "Backup codes can be used to access your account if you \ 649 + lose access to your authenticator device." ) 650 + </p> 651 + <ClientOnly 652 + fallback=(<div className="flex flex-col sm:w-1/2"> 653 + <span className="text-sm text-mist-80 mb-4"> 654 + (string 655 + ( string_of_int backupCodesRemaining 656 + ^ " backup codes remaining" ) ) 657 + </span> 658 + <Button type_="button"> 659 + (string "regenerate") 660 + </Button> 661 + </div>)> 662 + [%browser_only 663 + fun () -> 664 + let regenerateCodes () = 665 + setRegeneratingCodes (fun _ -> true) ; 666 + let _ = 667 + Fetch.fetchWithInit 668 + "/account/security/backup-codes/regenerate" 669 + (Fetch.RequestInit.make ~method_:Post 670 + ~headers: 671 + (Fetch.HeadersInit.makeWithArray 672 + [|("Content-Type", "application/json")|] ) 673 + () ) 674 + |> Js.Promise.then_ (fun response -> 675 + setRegeneratingCodes (fun _ -> false) ; 676 + if Fetch.Response.ok response then 677 + Fetch.Response.json response 678 + |> Js.Promise.then_ (fun json -> 679 + let codes = 680 + Js.Dict.unsafeGet (Obj.magic json) "codes" 681 + |> Obj.magic |> Js.Json.decodeArray 682 + |> Option.value ~default:[||] 683 + |> Array.map (fun s -> 684 + Js.Json.decodeString s 685 + |> Option.value ~default:"" ) 686 + in 687 + setBackupCodes (fun _ -> codes) ; 688 + setShowBackupCodes (fun _ -> true) ; 689 + setBackupCodesRemaining (fun _ -> 690 + Array.length codes ) ; 691 + Js.Promise.resolve () ) 692 + else Js.Promise.resolve () ) 693 + |> Js.Promise.catch (fun _ -> 694 + setRegeneratingCodes (fun _ -> false) ; 695 + Js.Promise.resolve () ) 696 + in 697 + () 698 + in 699 + <div className="flex flex-col sm:w-1/2"> 700 + <span className="text-sm text-mist-80 mb-4"> 701 + (string 702 + ( string_of_int backupCodesRemaining 703 + ^ " backup codes remaining" ) ) 704 + </span> 705 + <Button 706 + type_="button" 707 + disabled=regeneratingCodes 708 + onClick=(fun _ -> regenerateCodes ())> 709 + (string 710 + ( if regeneratingCodes then "regenerating..." 711 + else "regenerate" ) ) 712 + </Button> 713 + </div>] 714 + </ClientOnly> 715 + </div> 716 + else null ) 717 + </section> 718 + </main> 719 + </div>
+3 -1
frontend/src/templates/Layout.mlx
··· 33 33 </head> 34 34 <body 35 35 className="bg-feather-100 font-sans font-normal text-base tracking-normal"> 36 - <div id="root" className="flex justify-center min-h-screen">children</div> 36 + <div id="root" className="flex justify-center min-h-screen mb-16"> 37 + children 38 + </div> 37 39 </body> 38 40 </html>
+200 -115
frontend/src/templates/LoginPage.mlx
··· 3 3 open Melange_json.Primitives 4 4 open React 5 5 6 + type two_fa_methods = 7 + { totp: bool [@default false] 8 + ; email: bool [@default false] 9 + ; backup_code: bool [@default false] } 10 + [@@deriving json] 11 + 6 12 type props = 7 13 { redirect_url: string 8 14 ; csrf_token: string 9 - ; error: string option [@default None] } 15 + ; error: string option [@default None] 16 + ; two_fa_required: bool [@default false] 17 + ; pending_2fa_token: string option [@default None] 18 + ; two_fa_methods: two_fa_methods option [@default None] } 10 19 [@@deriving json] 11 20 12 - let[@react.component] make ~props:({redirect_url; csrf_token; error} : props) () 13 - = 21 + let[@react.component] make 22 + ~props: 23 + ({ redirect_url 24 + ; csrf_token 25 + ; error 26 + ; two_fa_required 27 + ; pending_2fa_token 28 + ; two_fa_methods } : 29 + props ) () = 14 30 let passkeyError, setPasskeyError = useState (fun () -> None) in 15 31 let passkeyLoading, setPasskeyLoading = useState (fun () -> false) in 16 32 let currentOptions = useRef (None : Js.Json.t option) in 17 33 let _ = 18 34 React.useEffect0 (fun () -> 19 - let _ = 20 - WebAuthn.browserSupportsWebAuthnAutofill () 21 - |> Js.Promise.then_ (fun supported -> 22 - if supported then begin 23 - Fetch.fetch "/account/passkeys/login/options" 24 - |> Js.Promise.then_ (fun response -> 25 - if Fetch.Response.ok response then 26 - Fetch.Response.json response 27 - else Js.Exn.raiseError "Failed to get options" ) 28 - |> Js.Promise.then_ (fun options -> 29 - currentOptions.current <- Some options ; 30 - WebAuthn.startAuthentication 31 - {optionsJSON= options; useBrowserAutofill= true} ) 32 - |> Js.Promise.then_ (fun credential -> 33 - setPasskeyLoading (fun _ -> true) ; 34 - let challenge = 35 - match currentOptions.current with 36 - | Some opts -> 37 - Js.Dict.unsafeGet (Obj.magic opts) "challenge" 38 - | None -> 39 - Js.Json.string "" 40 - in 41 - let body = 42 - Js.Json.object_ 43 - (Js.Dict.fromArray 44 - [| ( "response" 45 - , Js.Json.string (Js.Json.stringify credential) ) 46 - ; ("challenge", challenge) |] ) 47 - in 48 - Fetch.fetchWithInit 49 - ( "/account/passkeys/login/verify?redirect_url=" 50 - ^ Js.Global.encodeURIComponent redirect_url ) 51 - (Fetch.RequestInit.make ~method_:Post 52 - ~body:(Fetch.BodyInit.make (Js.Json.stringify body)) 53 - ~headers: 54 - (Fetch.HeadersInit.makeWithArray 55 - [|("Content-Type", "application/json")|] ) 56 - () ) ) 57 - |> Js.Promise.then_ (fun response -> 58 - setPasskeyLoading (fun _ -> false) ; 59 - if Fetch.Response.ok response then 60 - Fetch.Response.json response 61 - |> Js.Promise.then_ (fun json -> 62 - let redirect = 63 - Js.Dict.unsafeGet (Obj.magic json) "redirect" 64 - |> Js.Json.decodeString 65 - |> Option.value ~default:"/account" 66 - in 67 - Webapi.Dom.(Window.setLocation window redirect) ; 68 - Js.Promise.resolve () ) 69 - else begin 70 - setPasskeyError (fun _ -> 71 - Some "Passkey authentication failed" ) ; 72 - Js.Promise.resolve () 73 - end ) 74 - |> Js.Promise.catch (fun _ -> 75 - (* user cancelled or error *) 76 - Js.Promise.resolve () ) 77 - end 78 - else Js.Promise.resolve () ) 79 - in 80 - None ) 35 + (* only start passkey autofill if not in 2FA step *) 36 + if not two_fa_required then 37 + let _ = 38 + WebAuthn.browserSupportsWebAuthnAutofill () 39 + |> Js.Promise.then_ (fun supported -> 40 + if supported then begin 41 + Fetch.fetch "/account/passkeys/login/options" 42 + |> Js.Promise.then_ (fun response -> 43 + if Fetch.Response.ok response then 44 + Fetch.Response.json response 45 + else Js.Exn.raiseError "Failed to get options" ) 46 + |> Js.Promise.then_ (fun options -> 47 + currentOptions.current <- Some options ; 48 + WebAuthn.startAuthentication 49 + {optionsJSON= options; useBrowserAutofill= true} ) 50 + |> Js.Promise.then_ (fun credential -> 51 + setPasskeyLoading (fun _ -> true) ; 52 + let challenge = 53 + match currentOptions.current with 54 + | Some opts -> 55 + Js.Dict.unsafeGet (Obj.magic opts) "challenge" 56 + | None -> 57 + Js.Json.string "" 58 + in 59 + let body = 60 + Js.Json.object_ 61 + (Js.Dict.fromArray 62 + [| ( "response" 63 + , Js.Json.string (Js.Json.stringify credential) 64 + ) 65 + ; ("challenge", challenge) |] ) 66 + in 67 + Fetch.fetchWithInit 68 + ( "/account/passkeys/login/verify?redirect_url=" 69 + ^ Js.Global.encodeURIComponent redirect_url ) 70 + (Fetch.RequestInit.make ~method_:Post 71 + ~body:(Fetch.BodyInit.make (Js.Json.stringify body)) 72 + ~headers: 73 + (Fetch.HeadersInit.makeWithArray 74 + [|("Content-Type", "application/json")|] ) 75 + () ) ) 76 + |> Js.Promise.then_ (fun response -> 77 + setPasskeyLoading (fun _ -> false) ; 78 + if Fetch.Response.ok response then 79 + Fetch.Response.json response 80 + |> Js.Promise.then_ (fun json -> 81 + let redirect = 82 + Js.Dict.unsafeGet (Obj.magic json) "redirect" 83 + |> Js.Json.decodeString 84 + |> Option.value ~default:"/account" 85 + in 86 + Webapi.Dom.(Window.setLocation window redirect) ; 87 + Js.Promise.resolve () ) 88 + else begin 89 + setPasskeyError (fun _ -> 90 + Some "Passkey authentication failed" ) ; 91 + Js.Promise.resolve () 92 + end ) 93 + |> Js.Promise.catch (fun _ -> 94 + (* user cancelled or error *) 95 + Js.Promise.resolve () ) 96 + end 97 + else Js.Promise.resolve () ) 98 + in 99 + None 100 + else None ) 81 101 in 82 102 <main className="w-full h-auto max-w-xs px-4 sm:px-0 my-auto"> 83 103 <h1 className="text-2xl font-serif text-mana-200 mb-2"> 84 - (string "sign in") 104 + (string (if two_fa_required then "verify your identity" else "sign in")) 85 105 </h1> 86 - <span className="w-full text-balance text-mist-100"> 87 - (string "Enter your handle, email address, or DID, and your password.") 88 - </span> 89 - <form className="w-full flex flex-col mt-4 mb-2 gap-y-2"> 90 - <input type_="hidden" name="dream.csrf" value=csrf_token /> 91 - <Input 92 - sr_only=true 93 - name="identifier" 94 - type_="text" 95 - label="identifier" 96 - autoComplete="username webauthn" 97 - /> 98 - <Input sr_only=true name="password" type_="password" label="password" /> 99 - <input type_="hidden" name="redirect_url" value=redirect_url /> 100 - ( match error with 101 - | Some err -> 102 - <span className="inline-flex items-center text-phoenix-100 text-sm"> 103 - <CircleAlertIcon className="w-4 h-4 mr-2" /> (string err) 106 + ( if two_fa_required then 107 + (* 2FA verification step *) 108 + let methods = 109 + Option.value 110 + ~default:{totp= false; email= false; backup_code= false} 111 + two_fa_methods 112 + in 113 + <div> 114 + <span className="w-full text-balance text-mist-100"> 115 + ( if methods.totp then 116 + string "Enter the 6-digit code from your authenticator app." 117 + else if methods.email then 118 + string "Enter the verification code sent to your email." 119 + else string "Enter your verification code." ) 120 + </span> 121 + <form className="w-full flex flex-col mt-4 mb-2 gap-y-2"> 122 + <input type_="hidden" name="dream.csrf" value=csrf_token /> 123 + <input 124 + type_="hidden" 125 + name="pending_2fa_token" 126 + value=(Option.value ~default:"" pending_2fa_token) 127 + /> 128 + <input type_="hidden" name="redirect_url" value=redirect_url /> 129 + <Input 130 + sr_only=true 131 + name="two_fa_code" 132 + type_="text" 133 + label="verification code" 134 + autoComplete="one-time-code" 135 + inputMode="numeric" 136 + pattern="[0-9A-Za-z\\-]*" 137 + /> 138 + ( match error with 139 + | Some err -> 140 + <span 141 + className="inline-flex items-center text-phoenix-100 text-sm"> 142 + <CircleAlertIcon className="w-4 h-4 mr-2" /> (string err) 143 + </span> 144 + | None -> 145 + null ) 146 + <Button type_="submit" formMethod="post" className="mt-2"> 147 + (string "verify") 148 + </Button> 149 + </form> 150 + ( if methods.backup_code then 151 + <span className="text-sm text-mist-80"> 152 + (string "Lost access? You can use a backup code instead.") 153 + </span> 154 + else null ) 155 + <div className="mt-4"> 156 + <a 157 + className="text-sm text-mana-100 hover:text-mana-200" 158 + href="/account/login"> 159 + (string "cancel and start over") 160 + </a> 161 + </div> 162 + </div> 163 + else 164 + <div> 165 + <span className="w-full text-balance text-mist-100"> 166 + (string 167 + "Enter your handle, email address, or DID, and your password." ) 104 168 </span> 105 - | None -> 106 - null ) 107 - ( match passkeyError with 108 - | Some err -> 109 - <span className="inline-flex items-center text-phoenix-100 text-sm"> 110 - <CircleAlertIcon className="w-4 h-4 mr-2" /> (string err) 169 + <form className="w-full flex flex-col mt-4 mb-2 gap-y-2"> 170 + <input type_="hidden" name="dream.csrf" value=csrf_token /> 171 + <Input 172 + sr_only=true 173 + name="identifier" 174 + type_="text" 175 + label="identifier" 176 + autoComplete="username webauthn" 177 + /> 178 + <Input 179 + sr_only=true name="password" type_="password" label="password" 180 + /> 181 + <input type_="hidden" name="redirect_url" value=redirect_url /> 182 + ( match error with 183 + | Some err -> 184 + <span 185 + className="inline-flex items-center text-phoenix-100 text-sm"> 186 + <CircleAlertIcon className="w-4 h-4 mr-2" /> (string err) 187 + </span> 188 + | None -> 189 + null ) 190 + ( match passkeyError with 191 + | Some err -> 192 + <span 193 + className="inline-flex items-center text-phoenix-100 text-sm"> 194 + <CircleAlertIcon className="w-4 h-4 mr-2" /> (string err) 195 + </span> 196 + | None -> 197 + null ) 198 + <Button type_="submit" formMethod="post" className="mt-2"> 199 + (string (if passkeyLoading then "signing in..." else "sign in")) 200 + </Button> 201 + </form> 202 + <span className="text-sm text-mist-100"> 203 + (string "or: ") 204 + <ul className="mt-1 pl-2"> 205 + <li className="mb-1"> 206 + <a 207 + className="text-mana-100 underline hover:text-mana-200" 208 + href="/account/signup"> 209 + (string "create an account") 210 + </a> 211 + </li> 212 + <li> 213 + <a 214 + className="text-mana-100 underline hover:text-mana-200" 215 + href="/account/migrate"> 216 + (string "migrate from another PDS") 217 + </a> 218 + </li> 219 + </ul> 111 220 </span> 112 - | None -> 113 - null ) 114 - <Button type_="submit" formMethod="post" className="mt-2"> 115 - (string (if passkeyLoading then "signing in..." else "sign in")) 116 - </Button> 117 - </form> 118 - <span className="text-sm text-mist-100"> 119 - (string "or: ") 120 - <ul className="mt-1 pl-2"> 121 - <li className="mb-1"> 122 - <a 123 - className="text-mana-100 underline hover:text-mana-200" 124 - href="/account/signup"> 125 - (string "create an account") 126 - </a> 127 - </li> 128 - <li> 129 - <a 130 - className="text-mana-100 underline hover:text-mana-200" 131 - href="/account/migrate"> 132 - (string "migrate from another PDS") 133 - </a> 134 - </li> 135 - </ul> 136 - </span> 221 + </div> ) 137 222 </main>
+2 -1
package.json
··· 10 10 "author": "", 11 11 "license": "ISC", 12 12 "dependencies": { 13 + "@pedrobslisboa/react-client": "^19.1.0", 13 14 "@simplewebauthn/browser": "^11.0.0", 14 - "@pedrobslisboa/react-client": "^19.1.0", 15 + "qrcode.react": "^4.2.0", 15 16 "react": "^19.2.0", 16 17 "react-aria-components": "^1.13.0", 17 18 "react-dom": "^19.2.0",
+2 -40
pegasus/lib/api/account_/index.ml
··· 47 47 let email_change_pending = has_valid_email_change_code actor in 48 48 let pending_email = actor.pending_email in 49 49 let delete_pending = has_valid_delete_code actor in 50 - let%lwt passkeys_raw = 51 - Passkey.get_credentials_for_user ~did ctx.db 52 - in 53 - let passkeys = 54 - List.map 55 - (fun (pk : Passkey.Types.passkey) -> 56 - Frontend.AccountPage. 57 - { id= pk.id 58 - ; name= pk.name 59 - ; created_at= pk.created_at 60 - ; last_used_at= pk.last_used_at } ) 61 - passkeys_raw 62 - in 63 50 Util.render_html ~title:"Account" 64 51 (module Frontend.AccountPage) 65 52 ~props: ··· 78 65 ; delete_pending 79 66 ; error= None 80 67 ; success= None 81 - ; delete_error= None 82 - ; passkeys } ) ) 68 + ; delete_error= None } ) ) 83 69 84 70 let post_handler = 85 71 Xrpc.handler (fun ctx -> ··· 115 101 let email_change_pending = has_valid_email_change_code actor in 116 102 let pending_email = actor.pending_email in 117 103 let delete_pending = has_valid_delete_code actor in 118 - let%lwt passkeys_raw = 119 - Passkey.get_credentials_for_user ~did ctx.db 120 - in 121 - let passkeys = 122 - List.map 123 - (fun (pk : Passkey.Types.passkey) -> 124 - Frontend.AccountPage. 125 - { id= pk.id 126 - ; name= pk.name 127 - ; created_at= pk.created_at 128 - ; last_used_at= pk.last_used_at } ) 129 - passkeys_raw 130 - in 131 104 Util.render_html ~title:"Account" 132 105 (module Frontend.AccountPage) 133 106 ~props: ··· 146 119 ; delete_pending 147 120 ; error 148 121 ; success 149 - ; delete_error 150 - ; passkeys } 122 + ; delete_error } 151 123 in 152 124 match%lwt Dream.form ctx.req with 153 125 | `Ok fields -> ( ··· 158 130 List.assoc_opt "handle" fields 159 131 |> Option.value ~default:actor.handle 160 132 in 161 - let new_password = List.assoc_opt "password" fields in 162 133 (* update handle if changed *) 163 134 let%lwt handle_result = 164 135 if new_handle <> actor.handle then ··· 176 147 | Error (InternalServerError _) -> 177 148 render_page ~error:"Internal server error" () 178 149 | Ok () -> 179 - (* update password if provided *) 180 - let%lwt () = 181 - match new_password with 182 - | Some pw when String.length pw > 0 -> 183 - Data_store.update_password ~did ~password:pw 184 - ctx.db 185 - | _ -> 186 - Lwt.return_unit 187 - in 188 150 render_page ~success:"Changes saved." () ) 189 151 | Some "reactivate" -> 190 152 let%lwt () = Data_store.activate_actor did ctx.db in
+129 -16
pegasus/lib/api/account_/login.ml
··· 9 9 let csrf_token = Dream.csrf_token ctx.req in 10 10 Util.render_html ~title:"Login" 11 11 (module Frontend.LoginPage) 12 - ~props:{redirect_url; csrf_token; error= None} ) 12 + ~props: 13 + { redirect_url 14 + ; csrf_token 15 + ; error= None 16 + ; two_fa_required= false 17 + ; pending_2fa_token= None 18 + ; two_fa_methods= None } ) 13 19 14 20 type switch_account_response = 15 21 {success: bool; error: string option [@default None]} ··· 49 55 let csrf_token = Dream.csrf_token ctx.req in 50 56 match%lwt Dream.form ctx.req with 51 57 | `Ok fields -> ( 52 - let identifier = List.assoc "identifier" fields in 53 - let password = List.assoc "password" fields in 54 58 let redirect_url = 55 59 List.assoc_opt "redirect_url" fields 56 60 |> Option.value ~default:"/account" 57 61 in 58 - let%lwt actor = 59 - Data_store.try_login ~id:identifier ~password ctx.db 60 - in 61 - match actor with 62 - | None -> 63 - let error = "Invalid username or password. Please try again." in 64 - Util.render_html ~status:`Unauthorized ~title:"Login" 65 - (module Frontend.LoginPage) 66 - ~props:{redirect_url; csrf_token; error= Some error} 67 - | Some {did; _} -> 68 - let%lwt () = Session.log_in_did ctx.req did in 69 - Dream.redirect ctx.req redirect_url ) 62 + (* check if this is a 2FA verification step *) 63 + let pending_token = List.assoc_opt "pending_2fa_token" fields in 64 + let two_fa_code = List.assoc_opt "two_fa_code" fields in 65 + match (pending_token, two_fa_code) with 66 + | Some token, Some code -> ( 67 + match%lwt 68 + Two_factor.get_pending_session ~session_token:token ctx.db 69 + with 70 + | None -> 71 + let error = "Session expired. Please try again." in 72 + Util.render_html ~status:`Unauthorized ~title:"Login" 73 + (module Frontend.LoginPage) 74 + ~props: 75 + { redirect_url 76 + ; csrf_token 77 + ; error= Some error 78 + ; two_fa_required= false 79 + ; pending_2fa_token= None 80 + ; two_fa_methods= None } 81 + | Some pending -> ( 82 + (* try TOTP, then backup code, then email *) 83 + let%lwt result = 84 + let%lwt totp_result = 85 + Two_factor.verify_totp_code ~session_token:token ~code 86 + ctx.db 87 + in 88 + match totp_result with 89 + | Ok did -> 90 + Lwt.return_ok did 91 + | Error _ -> ( 92 + let%lwt backup_result = 93 + Two_factor.verify_backup_code ~session_token:token ~code 94 + ctx.db 95 + in 96 + match backup_result with 97 + | Ok did -> 98 + Lwt.return_ok did 99 + | Error _ -> 100 + Two_factor.verify_email_code_by_token 101 + ~session_token:token ~code ctx.db ) 102 + in 103 + match result with 104 + | Ok did -> 105 + let%lwt () = 106 + Two_factor.delete_pending_session ~session_token:token 107 + ctx.db 108 + in 109 + let%lwt () = Session.log_in_did ctx.req did in 110 + Dream.redirect ctx.req redirect_url 111 + | Error _ -> 112 + let%lwt methods = 113 + Two_factor.get_available_methods ~did:pending.did ctx.db 114 + in 115 + Util.render_html ~status:`Unauthorized ~title:"Login" 116 + (module Frontend.LoginPage) 117 + ~props: 118 + { redirect_url 119 + ; csrf_token 120 + ; error= Some "Invalid verification code" 121 + ; two_fa_required= true 122 + ; pending_2fa_token= Some token 123 + ; two_fa_methods= Some methods } ) ) 124 + | _ -> ( 125 + let identifier = List.assoc "identifier" fields in 126 + let password = List.assoc "password" fields in 127 + let%lwt actor = 128 + Data_store.try_login ~id:identifier ~password ctx.db 129 + in 130 + match actor with 131 + | None -> 132 + let error = 133 + "Invalid username or password. Please try again." 134 + in 135 + Util.render_html ~status:`Unauthorized ~title:"Login" 136 + (module Frontend.LoginPage) 137 + ~props: 138 + { redirect_url 139 + ; csrf_token 140 + ; error= Some error 141 + ; two_fa_required= false 142 + ; pending_2fa_token= None 143 + ; two_fa_methods= None } 144 + | Some actor -> 145 + let%lwt is_2fa_enabled = 146 + Two_factor.is_2fa_enabled ~did:actor.did ctx.db 147 + in 148 + if is_2fa_enabled then 149 + let%lwt session_token = 150 + Two_factor.create_pending_session ~did:actor.did ctx.db 151 + in 152 + let%lwt methods = 153 + Two_factor.get_available_methods ~did:actor.did ctx.db 154 + in 155 + (* if email-only 2FA, send email code now *) 156 + let%lwt () = 157 + if methods.email && not methods.totp then 158 + let%lwt () = 159 + Two_factor.send_email_code ~session_token ~actor 160 + ctx.db 161 + in 162 + Lwt.return () 163 + else Lwt.return () 164 + in 165 + Util.render_html ~title:"Login" 166 + (module Frontend.LoginPage) 167 + ~props: 168 + { redirect_url 169 + ; csrf_token 170 + ; error= None 171 + ; two_fa_required= true 172 + ; pending_2fa_token= Some session_token 173 + ; two_fa_methods= Some methods } 174 + else 175 + let%lwt () = Session.log_in_did ctx.req actor.did in 176 + Dream.redirect ctx.req redirect_url ) ) 70 177 | _ -> 71 178 let redirect_url = "/account" in 72 179 let error = "Something went wrong, go back and try again." in 73 180 Util.render_html ~status:`Unauthorized ~title:"Login" 74 181 (module Frontend.LoginPage) 75 - ~props:{redirect_url; csrf_token; error= Some error} ) 182 + ~props: 183 + { redirect_url 184 + ; csrf_token 185 + ; error= Some error 186 + ; two_fa_required= false 187 + ; pending_2fa_token= None 188 + ; two_fa_methods= None } )
+65 -73
pegasus/lib/api/account_/passkeys.ml
··· 23 23 24 24 type error_response = {error: string} [@@deriving yojson {strict= false}] 25 25 26 - (* helper to get the current DID or return unauthorized *) 27 - let with_current_did ctx f = 28 - match%lwt Session.Raw.get_current_did ctx.Xrpc.req with 29 - | None -> 30 - Errors.auth_required "not authorized" 31 - | Some did -> 32 - f did 33 - 34 26 let list_handler = 35 27 Xrpc.handler (fun ctx -> 36 - with_current_did ctx (fun did -> 37 - let%lwt passkeys = Passkey.get_credentials_for_user ~did ctx.db in 38 - let passkey_infos = 39 - List.map 40 - (fun (pk : Passkey.Types.passkey) -> 41 - { id= pk.id 42 - ; name= pk.name 43 - ; created_at= pk.created_at 44 - ; last_used_at= pk.last_used_at } ) 45 - passkeys 46 - in 47 - Dream.json @@ Yojson.Safe.to_string 48 - @@ list_response_to_yojson {passkeys= passkey_infos} ) ) 28 + let%lwt did = Session.get_current_did_exn ctx.req in 29 + let%lwt passkeys = Passkey.get_credentials_for_user ~did ctx.db in 30 + let passkey_infos = 31 + List.map 32 + (fun (pk : Passkey.Types.passkey) -> 33 + { id= pk.id 34 + ; name= pk.name 35 + ; created_at= pk.created_at 36 + ; last_used_at= pk.last_used_at } ) 37 + passkeys 38 + in 39 + Dream.json @@ Yojson.Safe.to_string 40 + @@ list_response_to_yojson {passkeys= passkey_infos} ) 49 41 50 42 let register_options_handler = 51 43 Xrpc.handler (fun ctx -> 52 - with_current_did ctx (fun did -> 53 - match%lwt Data_store.get_actor_by_identifier did ctx.db with 54 - | None -> 55 - Errors.auth_required "user not found" 56 - | Some actor -> 57 - let%lwt existing = Passkey.get_credentials_for_user ~did ctx.db in 58 - let%lwt options = 59 - Passkey.generate_registration_options ~did ~email:actor.email 60 - ~existing_credentials:existing ctx.db 61 - in 62 - Dream.json @@ Yojson.Safe.to_string options ) ) 44 + let%lwt did = Session.get_current_did_exn ctx.req in 45 + match%lwt Data_store.get_actor_by_identifier did ctx.db with 46 + | None -> 47 + Errors.auth_required "user not found" 48 + | Some actor -> 49 + let%lwt existing = Passkey.get_credentials_for_user ~did ctx.db in 50 + let%lwt options = 51 + Passkey.generate_registration_options ~did ~email:actor.email 52 + ~existing_credentials:existing ctx.db 53 + in 54 + Dream.json @@ Yojson.Safe.to_string options ) 63 55 64 56 let register_verify_handler = 65 57 Xrpc.handler (fun ctx -> 66 - with_current_did ctx (fun did -> 67 - let%lwt {challenge; response; name; _} = 68 - Xrpc.parse_body ctx.req register_request_of_yojson 58 + let%lwt did = Session.get_current_did_exn ctx.req in 59 + let%lwt {challenge; response; name; _} = 60 + Xrpc.parse_body ctx.req register_request_of_yojson 61 + in 62 + match%lwt Passkey.verify_registration ~challenge ~response ctx.db with 63 + | Error msg -> 64 + Errors.invalid_request msg 65 + | Ok (credential_id, public_key) -> 66 + let%lwt () = 67 + Passkey.store_credential ~did ~credential_id ~public_key ~name 68 + ctx.db 69 69 in 70 - match%lwt Passkey.verify_registration ~challenge ~response ctx.db with 71 - | Error msg -> 72 - Errors.invalid_request msg 73 - | Ok (credential_id, public_key) -> 74 - let%lwt () = 75 - Passkey.store_credential ~did ~credential_id ~public_key ~name 76 - ctx.db 77 - in 78 - Dream.json @@ Yojson.Safe.to_string 79 - @@ success_response_to_yojson 80 - {success= true; message= Some "Passkey registered"} ) ) 70 + Dream.json @@ Yojson.Safe.to_string 71 + @@ success_response_to_yojson 72 + {success= true; message= Some "Passkey registered"} ) 81 73 82 74 let login_options_handler = 83 75 Xrpc.handler (fun ctx -> ··· 104 96 105 97 let delete_handler = 106 98 Xrpc.handler (fun ctx -> 107 - with_current_did ctx (fun did -> 108 - let id_str = Dream.param ctx.req "id" in 109 - match int_of_string_opt id_str with 110 - | None -> 111 - Errors.invalid_request "invalid passkey id" 112 - | Some id -> 113 - let%lwt _ = Passkey.delete_credential ~id ~did ctx.db in 114 - Dream.json @@ Yojson.Safe.to_string 115 - @@ success_response_to_yojson 116 - {success= true; message= Some "Passkey deleted"} ) ) 99 + let%lwt did = Session.get_current_did_exn ctx.req in 100 + let id_str = Dream.param ctx.req "id" in 101 + match int_of_string_opt id_str with 102 + | None -> 103 + Errors.invalid_request "invalid passkey id" 104 + | Some id -> 105 + let%lwt _ = Passkey.delete_credential ~id ~did ctx.db in 106 + Dream.json @@ Yojson.Safe.to_string 107 + @@ success_response_to_yojson 108 + {success= true; message= Some "Passkey deleted"} ) 117 109 118 110 let rename_handler = 119 111 Xrpc.handler (fun ctx -> 120 - with_current_did ctx (fun did -> 121 - let id_str = Dream.param ctx.req "id" in 122 - match int_of_string_opt id_str with 123 - | None -> 124 - Errors.invalid_request "invalid passkey id" 125 - | Some id -> ( 126 - let%lwt body = Dream.body ctx.req in 127 - let json = Yojson.Safe.from_string body in 128 - match Yojson.Safe.Util.member "name" json with 129 - | `String name -> 130 - let%lwt _success = 131 - Passkey.rename_credential ~id ~did ~name ctx.db 132 - in 133 - Dream.json @@ Yojson.Safe.to_string 134 - @@ success_response_to_yojson {success= true; message= None} 135 - | _ -> 136 - Errors.invalid_request "missing name field" ) ) ) 112 + let%lwt did = Session.get_current_did_exn ctx.req in 113 + let id_str = Dream.param ctx.req "id" in 114 + match int_of_string_opt id_str with 115 + | None -> 116 + Errors.invalid_request "invalid passkey id" 117 + | Some id -> ( 118 + let%lwt body = Dream.body ctx.req in 119 + let json = Yojson.Safe.from_string body in 120 + match Yojson.Safe.Util.member "name" json with 121 + | `String name -> 122 + let%lwt _success = 123 + Passkey.rename_credential ~id ~did ~name ctx.db 124 + in 125 + Dream.json @@ Yojson.Safe.to_string 126 + @@ success_response_to_yojson {success= true; message= None} 127 + | _ -> 128 + Errors.invalid_request "missing name field" ) )
+23
pegasus/lib/api/account_/security/backup_codes.ml
··· 1 + type count_response = {remaining: int} [@@deriving yojson {strict= false}] 2 + 3 + type regenerate_response = {success: bool; codes: string list} 4 + [@@deriving yojson {strict= false}] 5 + 6 + let count_handler = 7 + Xrpc.handler (fun ctx -> 8 + let%lwt did = Session.get_current_did_exn ctx.req in 9 + let%lwt count = Totp.Backup_codes.get_remaining_count ~did ctx.db in 10 + Dream.json @@ Yojson.Safe.to_string 11 + @@ count_response_to_yojson {remaining= count} ) 12 + 13 + let regenerate_handler = 14 + Xrpc.handler (fun ctx -> 15 + let%lwt did = Session.get_current_did_exn ctx.req in 16 + (* only allow regeneration if 2FA is enabled *) 17 + let%lwt is_2fa_enabled = Two_factor.is_2fa_enabled ~did ctx.db in 18 + if not is_2fa_enabled then 19 + Errors.invalid_request "2FA must be enabled to generate backup codes" 20 + else 21 + let%lwt codes = Totp.Backup_codes.regenerate ~did ctx.db in 22 + Dream.json @@ Yojson.Safe.to_string 23 + @@ regenerate_response_to_yojson {success= true; codes} )
+103
pegasus/lib/api/account_/security/index.ml
··· 1 + type passkey_info = 2 + { id: int 3 + ; name: string 4 + ; created_at: int 5 + ; last_used_at: int option [@default None] } 6 + [@@deriving yojson {strict= false}] 7 + 8 + type success_response = {success: bool; message: string option [@default None]} 9 + [@@deriving yojson {strict= false}] 10 + 11 + type error_response = {error: string} [@@deriving yojson {strict= false}] 12 + 13 + let get_handler = 14 + Xrpc.handler (fun ctx -> 15 + let%lwt did = Session.get_current_did_exn ctx.req in 16 + let%lwt current_user, logged_in_users = 17 + Session.list_logged_in_actors ctx.req ctx.db 18 + in 19 + match%lwt Data_store.get_actor_by_identifier did ctx.db with 20 + | None -> 21 + Dream.redirect ctx.req "/account/login" 22 + | Some actor -> 23 + let current_user = 24 + Option.value 25 + ~default: 26 + {did= actor.did; handle= actor.handle; avatar_data_uri= None} 27 + current_user 28 + in 29 + let csrf_token = Dream.csrf_token ctx.req in 30 + let%lwt passkeys = Passkey.get_credentials_for_user ~did ctx.db in 31 + let passkey_list = 32 + List.map 33 + (fun (pk : Passkey.Types.passkey) -> 34 + ( { id= pk.id 35 + ; name= pk.name 36 + ; created_at= pk.created_at 37 + ; last_used_at= pk.last_used_at } 38 + : Frontend.AccountSecurityPage.passkey_display ) ) 39 + passkeys 40 + in 41 + let%lwt two_fa_status = Two_factor.get_status ~did ctx.db in 42 + let error = Dream.query ctx.req "error" in 43 + let success = Dream.query ctx.req "success" in 44 + Util.render_html ~title:"Security" 45 + (module Frontend.AccountSecurityPage) 46 + ~props: 47 + { current_user 48 + ; logged_in_users 49 + ; csrf_token 50 + ; passkeys= passkey_list 51 + ; totp_enabled= two_fa_status.totp_enabled 52 + ; email_2fa_enabled= two_fa_status.email_2fa_enabled 53 + ; backup_codes_remaining= two_fa_status.backup_codes_remaining 54 + ; error 55 + ; success } ) 56 + 57 + let post_handler = 58 + Xrpc.handler (fun ctx -> 59 + let%lwt did = Session.get_current_did_exn ctx.req in 60 + match%lwt Dream.form ctx.req with 61 + | `Ok fields -> ( 62 + let action = List.assoc_opt "action" fields in 63 + match action with 64 + | Some "change_password" -> ( 65 + let current_password = List.assoc_opt "current_password" fields in 66 + let new_password = List.assoc_opt "new_password" fields in 67 + let confirm_password = List.assoc_opt "confirm_password" fields in 68 + match (current_password, new_password, confirm_password) with 69 + | Some current, Some new_pw, Some confirm when new_pw = confirm 70 + -> ( 71 + match%lwt Data_store.get_actor_by_identifier did ctx.db with 72 + | None -> 73 + Dream.redirect ctx.req 74 + "/account/security?error=User+not+found." 75 + | Some actor -> 76 + let hash = Bcrypt.hash_of_string actor.password_hash in 77 + if Bcrypt.verify current hash then 78 + let%lwt () = 79 + Data_store.update_password ~did ~password:new_pw ctx.db 80 + in 81 + Dream.redirect ctx.req 82 + "/account/security?success=Password+updated%21" 83 + else 84 + Dream.redirect ctx.req 85 + "/account/security?error=Incorrect+current+password." ) 86 + | Some _, Some _, Some _ -> 87 + Dream.redirect ctx.req 88 + "/account/security?error=Passwords+do+not+match." 89 + | _ -> 90 + Dream.redirect ctx.req 91 + "/account/security?error=Missing+required+fields." ) 92 + | Some "enable_email_2fa" -> 93 + let%lwt () = Two_factor.enable_email_2fa ~did ctx.db in 94 + Dream.redirect ctx.req 95 + "/account/security?success=Email+2FA+enabled%21" 96 + | Some "disable_email_2fa" -> 97 + let%lwt () = Two_factor.disable_email_2fa ~did ctx.db in 98 + Dream.redirect ctx.req 99 + "/account/security?success=Email+2FA+disabled." 100 + | _ -> 101 + Dream.redirect ctx.req "/account/security" ) 102 + | _ -> 103 + Dream.redirect ctx.req "/account/security" )
+56
pegasus/lib/api/account_/security/totp.ml
··· 1 + type setup_response = {secret: string; uri: string} 2 + [@@deriving yojson {strict= false}] 3 + 4 + type verify_request = {code: string} [@@deriving yojson {strict= false}] 5 + 6 + type verify_response = 7 + {success: bool; backup_codes: string list option [@default None]} 8 + [@@deriving yojson {strict= false}] 9 + 10 + type success_response = {success: bool; message: string option [@default None]} 11 + [@@deriving yojson {strict= false}] 12 + 13 + let setup_handler = 14 + Xrpc.handler (fun ctx -> 15 + let%lwt did = Session.get_current_did_exn ctx.req in 16 + match%lwt Data_store.get_actor_by_identifier did ctx.db with 17 + | None -> 18 + Errors.auth_required "user not found" 19 + | Some actor -> 20 + let%lwt already_enabled = Totp.is_enabled ~did ctx.db in 21 + if already_enabled then 22 + Errors.invalid_request "TOTP is already enabled" 23 + else 24 + let secret = Totp.generate_secret () in 25 + let issuer = "Pegasus PDS (" ^ Env.hostname ^ ")" in 26 + let uri = 27 + Totp.make_provisioning_uri ~secret ~email:actor.email ~issuer 28 + in 29 + let secret_b32 = 30 + Multibase.Base32.encode_exn ~pad:false (Bytes.to_string secret) 31 + in 32 + let%lwt () = Totp.create_secret ~did ~secret ctx.db in 33 + Dream.json @@ Yojson.Safe.to_string 34 + @@ setup_response_to_yojson {secret= secret_b32; uri} ) 35 + 36 + let verify_handler = 37 + Xrpc.handler (fun ctx -> 38 + let%lwt did = Session.get_current_did_exn ctx.req in 39 + let%lwt {code; _} = Xrpc.parse_body ctx.req verify_request_of_yojson in 40 + match%lwt Totp.verify_and_enable ~did ~code ctx.db with 41 + | Error msg -> 42 + Errors.invalid_request msg 43 + | Ok () -> 44 + let%lwt backup_codes = 45 + Totp.Backup_codes.ensure_codes_exist ~did ctx.db 46 + in 47 + Dream.json @@ Yojson.Safe.to_string 48 + @@ verify_response_to_yojson {success= true; backup_codes} ) 49 + 50 + let disable_handler = 51 + Xrpc.handler (fun ctx -> 52 + let%lwt did = Session.get_current_did_exn ctx.req in 53 + let%lwt () = Totp.disable ~did ctx.db in 54 + Dream.json @@ Yojson.Safe.to_string 55 + @@ success_response_to_yojson 56 + {success= true; message= Some "TOTP disabled"} )
+71 -21
pegasus/lib/api/server/createSession.ml
··· 2 2 3 3 let consume_points = 1 4 4 5 + let complete_login (actor : Data_store.Types.actor) = 6 + let access_jwt, refresh_jwt = Jwt.generate_jwt actor.did in 7 + let active, status = 8 + match actor.deactivated_at with 9 + | None -> 10 + (Some true, None) 11 + | Some _ -> 12 + (Some false, Some "deactivated") 13 + in 14 + Dream.json @@ Yojson.Safe.to_string 15 + @@ output_to_yojson 16 + { access_jwt 17 + ; refresh_jwt 18 + ; handle= actor.handle 19 + ; did= actor.did 20 + ; email= Some actor.email 21 + ; email_confirmed= Some (Option.is_some actor.email_confirmed_at) 22 + ; email_auth_factor= 23 + Some (actor.email_2fa_enabled = 1 || actor.totp_verified_at <> None) 24 + ; active 25 + ; status 26 + ; did_doc= None } 27 + 28 + let verify_2fa_code ~(actor : Data_store.Types.actor) ~code db = 29 + let did = actor.did in 30 + let%lwt totp_valid = Totp.verify_login_code ~did ~code db in 31 + if totp_valid then Lwt.return_ok () 32 + else 33 + let%lwt backup_valid = Totp.Backup_codes.verify_and_consume ~did ~code db in 34 + if backup_valid then Lwt.return_ok () 35 + else 36 + match%lwt Two_factor.verify_email_code_by_did ~did ~code db with 37 + | Ok _ -> 38 + Lwt.return_ok () 39 + | Error e -> 40 + Lwt.return_error e 41 + 5 42 let handler = 6 43 Xrpc.handler (fun {req; db; _} -> 7 - let%lwt {identifier; password; _} = Xrpc.parse_body req input_of_yojson in 44 + let%lwt {identifier; password; auth_factor_token; _} = 45 + Xrpc.parse_body req input_of_yojson 46 + in 8 47 let id = String.lowercase_ascii identifier in 9 48 (* apply rate limits after parsing body so we can create key from identifier *) 10 49 let key = id ^ "-" ^ Util.request_ip req in ··· 19 58 match%lwt 20 59 Lwt_result.catch @@ fun () -> Data_store.try_login ~id ~password db 21 60 with 22 - | Ok (Some actor) -> 23 - let access_jwt, refresh_jwt = Jwt.generate_jwt actor.did in 24 - let active, status = 25 - match actor.deactivated_at with 26 - | None -> 27 - (Some true, None) 28 - | Some _ -> 29 - (Some false, Some "deactivated") 61 + | Ok (Some actor) -> ( 62 + let is_2fa_enabled = 63 + actor.email_2fa_enabled = 1 || actor.totp_verified_at <> None 30 64 in 31 - Dream.json @@ Yojson.Safe.to_string 32 - @@ output_to_yojson 33 - { access_jwt 34 - ; refresh_jwt 35 - ; handle= actor.handle 36 - ; did= actor.did 37 - ; email= Some actor.email 38 - ; email_confirmed= Some true 39 - ; email_auth_factor= Some true 40 - ; active 41 - ; status 42 - ; did_doc= None } 65 + if not is_2fa_enabled then complete_login actor 66 + else 67 + match auth_factor_token with 68 + | Some token when token <> "" -> ( 69 + match%lwt verify_2fa_code ~actor ~code:token db with 70 + | Ok () -> 71 + complete_login actor 72 + | Error msg -> 73 + Errors.auth_required ~name:"InvalidToken" msg ) 74 + | _ -> 75 + (* no token provided, need to request 2FA *) 76 + let%lwt methods = 77 + Two_factor.get_available_methods ~did:actor.did db 78 + in 79 + (* only send code to email if email is the only method *) 80 + let%lwt () = 81 + if methods.email && not methods.totp then 82 + let%lwt session_token = 83 + Two_factor.create_pending_session ~did:actor.did db 84 + in 85 + let%lwt () = 86 + Two_factor.send_email_code ~session_token ~actor db 87 + in 88 + Lwt.return () 89 + else Lwt.return () 90 + in 91 + Errors.auth_required ~name:"AuthFactorTokenRequired" 92 + "Two-factor authentication required" ) 43 93 | Ok _ -> 44 94 Errors.invalid_request "invalid credentials" 45 95 | Error e ->
+2 -1
pegasus/lib/auth.ml
··· 151 151 ; handle= actor.handle 152 152 ; email= Some actor.email 153 153 ; email_confirmed= Some (actor.email_confirmed_at <> None) 154 - ; email_auth_factor= Some true 154 + ; email_auth_factor= 155 + Some (actor.email_2fa_enabled = 1 || actor.totp_verified_at <> None) 155 156 ; active 156 157 ; status } 157 158
+9 -6
pegasus/lib/data_store.ml
··· 14 14 ; deactivated_at: int option 15 15 ; auth_code: string option 16 16 ; auth_code_expires_at: int option 17 - ; pending_email: string option } 17 + ; pending_email: string option 18 + ; email_2fa_enabled: int 19 + ; totp_secret: bytes option 20 + ; totp_verified_at: int option } 18 21 19 22 type invite_code = {code: string; did: string; remaining: int} 20 23 ··· 52 55 let get_actor_by_identifier id = 53 56 [%rapper 54 57 get_opt 55 - {sql| SELECT @int{id}, @string{did}, @string{handle}, @string{email}, @int?{email_confirmed_at}, @string{password_hash}, @string{signing_key}, @Json{preferences}, @int{created_at}, @int?{deactivated_at}, @string?{auth_code}, @int?{auth_code_expires_at}, @string?{pending_email} 58 + {sql| SELECT @int{id}, @string{did}, @string{handle}, @string{email}, @int?{email_confirmed_at}, @string{password_hash}, @string{signing_key}, @Json{preferences}, @int{created_at}, @int?{deactivated_at}, @string?{auth_code}, @int?{auth_code_expires_at}, @string?{pending_email}, COALESCE(@int{email_2fa_enabled}, 0), @Blob?{totp_secret}, @int?{totp_verified_at} 56 59 FROM actors WHERE did = %string{id} OR handle = %string{id} OR email = %string{id} 57 60 LIMIT 1 58 61 |sql} ··· 85 88 let list_actors = 86 89 [%rapper 87 90 get_many 88 - {sql| SELECT @int{id}, @string{did}, @string{handle}, @string{email}, @int?{email_confirmed_at}, @string{password_hash}, @string{signing_key}, @Json{preferences}, @int{created_at}, @int?{deactivated_at}, @string?{auth_code}, @int?{auth_code_expires_at}, @string?{pending_email} 91 + {sql| SELECT @int{id}, @string{did}, @string{handle}, @string{email}, @int?{email_confirmed_at}, @string{password_hash}, @string{signing_key}, @Json{preferences}, @int{created_at}, @int?{deactivated_at}, @string?{auth_code}, @int?{auth_code_expires_at}, @string?{pending_email}, COALESCE(@int{email_2fa_enabled}, 0), @Blob?{totp_secret}, @int?{totp_verified_at} 89 92 FROM actors 90 93 WHERE did > %string{cursor} 91 94 AND deactivated_at IS NULL ··· 149 152 let list_actors_filtered = 150 153 [%rapper 151 154 get_many 152 - {sql| SELECT @int{id}, @string{did}, @string{handle}, @string{email}, @int?{email_confirmed_at}, @string{password_hash}, @string{signing_key}, @Json{preferences}, @int{created_at}, @int?{deactivated_at}, @string?{auth_code}, @int?{auth_code_expires_at}, @string?{pending_email} 155 + {sql| SELECT @int{id}, @string{did}, @string{handle}, @string{email}, @int?{email_confirmed_at}, @string{password_hash}, @string{signing_key}, @Json{preferences}, @int{created_at}, @int?{deactivated_at}, @string?{auth_code}, @int?{auth_code_expires_at}, @string?{pending_email}, COALESCE(@int{email_2fa_enabled}, 0), @Blob?{totp_secret}, @int?{totp_verified_at} 153 156 FROM actors 154 157 WHERE (did LIKE '%' || %string{filter} || '%' 155 158 OR handle LIKE '%' || %string{filter} || '%' ··· 162 165 let list_all_actors = 163 166 [%rapper 164 167 get_many 165 - {sql| SELECT @int{id}, @string{did}, @string{handle}, @string{email}, @int?{email_confirmed_at}, @string{password_hash}, @string{signing_key}, @Json{preferences}, @int{created_at}, @int?{deactivated_at}, @string?{auth_code}, @int?{auth_code_expires_at}, @string?{pending_email} 168 + {sql| SELECT @int{id}, @string{did}, @string{handle}, @string{email}, @int?{email_confirmed_at}, @string{password_hash}, @string{signing_key}, @Json{preferences}, @int{created_at}, @int?{deactivated_at}, @string?{auth_code}, @int?{auth_code_expires_at}, @string?{pending_email}, COALESCE(@int{email_2fa_enabled}, 0), @Blob?{totp_secret}, @int?{totp_verified_at} 166 169 FROM actors 167 170 WHERE did > %string{cursor} 168 171 ORDER BY did ASC LIMIT %int{limit} ··· 232 235 let get_actor_by_auth_code code = 233 236 [%rapper 234 237 get_opt 235 - {sql| SELECT @int{id}, @string{did}, @string{handle}, @string{email}, @int?{email_confirmed_at}, @string{password_hash}, @string{signing_key}, @Json{preferences}, @int{created_at}, @int?{deactivated_at}, @string?{auth_code}, @int?{auth_code_expires_at}, @string?{pending_email} 238 + {sql| SELECT @int{id}, @string{did}, @string{handle}, @string{email}, @int?{email_confirmed_at}, @string{password_hash}, @string{signing_key}, @Json{preferences}, @int{created_at}, @int?{deactivated_at}, @string?{auth_code}, @int?{auth_code_expires_at}, @string?{pending_email}, COALESCE(@int{email_2fa_enabled}, 0), @Blob?{totp_secret}, @int?{totp_verified_at} 236 239 FROM actors WHERE auth_code = %string{code} 237 240 LIMIT 1 238 241 |sql}
+45
pegasus/lib/emails/twoFactorAuth.mlx
··· 1 + open EmailStyles 2 + 3 + let html_body ~handle ~code : JSX.element = 4 + <div> 5 + <p style=Styles.paragraph> 6 + (JSX.string "Hello, ") 7 + <span style=Styles.link>(JSX.string handle)</span> 8 + (JSX.string "!") 9 + </p> 10 + <p style=Styles.paragraph> 11 + (JSX.string "Your login verification code is:") 12 + </p> 13 + <div style=Styles.code_block>(JSX.string code)</div> 14 + <p style=Styles.small_text> 15 + (JSX.string "This code will expire in 10 minutes.") 16 + </p> 17 + <p style=Styles.small_text> 18 + (JSX.string 19 + "If you didn't try to sign in, you should change your password \ 20 + immediately." ) 21 + </p> 22 + </div> 23 + 24 + let plain_text ~handle ~code : string = 25 + Printf.sprintf 26 + "Hello, %s!\n\n\ 27 + Your login verification code is:\n\n\ 28 + %s\n\n\ 29 + This code will expire in 10 minutes.\n\n\ 30 + If you didn't try to sign in, you should change your password immediately." 31 + handle code 32 + 33 + let make ~handle ~code : Letters.body = 34 + let html_content = html_body ~handle ~code in 35 + let template_content = 36 + EmailTemplate. 37 + { title= "Login verification code for " ^ handle 38 + ; heading= "verify your identity" 39 + ; body= html_content 40 + ; footer= Some ("This is an automated message from " ^ Env.hostname ^ ".") 41 + } 42 + in 43 + let html = EmailTemplate.make ~content:template_content () |> JSX.render in 44 + let plain = plain_text ~handle ~code in 45 + Mixed (plain, html, None)
+35
pegasus/lib/migrations/data_store/006_two_factor_auth_extended.sql
··· 1 + ALTER TABLE actors ADD COLUMN totp_secret BLOB; 2 + ALTER TABLE actors ADD COLUMN totp_verified_at INTEGER; 3 + 4 + ALTER TABLE actors ADD COLUMN email_2fa_enabled INTEGER DEFAULT 0; 5 + 6 + CREATE TABLE IF NOT EXISTS backup_codes ( 7 + id INTEGER PRIMARY KEY, 8 + did TEXT NOT NULL, 9 + code_hash TEXT NOT NULL, 10 + used_at INTEGER, 11 + created_at INTEGER NOT NULL, 12 + FOREIGN KEY (did) REFERENCES actors(did) ON DELETE CASCADE 13 + ); 14 + 15 + CREATE INDEX IF NOT EXISTS backup_codes_did_idx ON backup_codes(did); 16 + 17 + CREATE TABLE IF NOT EXISTS pending_2fa ( 18 + id INTEGER PRIMARY KEY, 19 + session_token TEXT NOT NULL UNIQUE, 20 + did TEXT NOT NULL, 21 + password_verified_at INTEGER NOT NULL, 22 + expires_at INTEGER NOT NULL, 23 + email_code TEXT, 24 + email_code_expires_at INTEGER, 25 + created_at INTEGER NOT NULL 26 + ); 27 + 28 + CREATE INDEX IF NOT EXISTS pending_2fa_session_idx ON pending_2fa(session_token); 29 + CREATE INDEX IF NOT EXISTS pending_2fa_expires_idx ON pending_2fa(expires_at); 30 + 31 + CREATE TRIGGER IF NOT EXISTS cleanup_expired_pending_2fa 32 + AFTER INSERT ON pending_2fa 33 + BEGIN 34 + DELETE FROM pending_2fa WHERE expires_at < unixepoch() * 1000; 35 + END;
+7
pegasus/lib/session.ml
··· 113 113 114 114 open Raw 115 115 116 + let get_current_did_exn req = 117 + match%lwt get_current_did req with 118 + | None -> 119 + Errors.auth_required "not authorized" 120 + | Some did -> 121 + Lwt.return did 122 + 116 123 let log_in_did req did = 117 124 match%lwt get_session req with 118 125 | Some {logged_in_dids; session_id; admin_authenticated; _} ->
+285
pegasus/lib/totp.ml
··· 1 + open Util.Rapper 2 + 3 + let secret_length = 20 (* 160 bits for HMAC-SHA1 *) 4 + 5 + let time_step = 30 (* 30 second intervals *) 6 + 7 + let code_digits = 6 8 + 9 + let window_size = 1 10 + 11 + module Backup_codes = struct 12 + let code_count = 10 13 + 14 + let code_length = 8 15 + 16 + module Types = struct 17 + type backup_code = 18 + { id: int 19 + ; did: string 20 + ; code_hash: string 21 + ; used_at: int option 22 + ; created_at: int } 23 + end 24 + 25 + open Types 26 + 27 + module Queries = struct 28 + let insert_backup_code = 29 + [%rapper 30 + execute 31 + {sql| INSERT INTO backup_codes (did, code_hash, created_at) 32 + VALUES (%string{did}, %string{code_hash}, %int{created_at}) 33 + |sql}] 34 + 35 + let get_backup_codes_by_did did = 36 + [%rapper 37 + get_many 38 + {sql| SELECT @int{id}, @string{did}, @string{code_hash}, @int?{used_at}, @int{created_at} 39 + FROM backup_codes WHERE did = %string{did} 40 + ORDER BY created_at ASC 41 + |sql} 42 + record_out] 43 + did 44 + 45 + let get_unused_backup_codes_by_did did = 46 + [%rapper 47 + get_many 48 + {sql| SELECT @int{id}, @string{did}, @string{code_hash}, @int?{used_at}, @int{created_at} 49 + FROM backup_codes WHERE did = %string{did} AND used_at IS NULL 50 + ORDER BY created_at ASC 51 + |sql} 52 + record_out] 53 + did 54 + 55 + let mark_code_used = 56 + [%rapper 57 + execute 58 + {sql| UPDATE backup_codes SET used_at = %int{used_at} 59 + WHERE id = %int{id} AND did = %string{did} 60 + |sql}] 61 + 62 + let delete_backup_codes_by_did = 63 + [%rapper 64 + execute {sql| DELETE FROM backup_codes WHERE did = %string{did} |sql}] 65 + 66 + let count_unused_backup_codes did = 67 + [%rapper 68 + get_one 69 + {sql| SELECT COUNT(*) AS @int{count} 70 + FROM backup_codes WHERE did = %string{did} AND used_at IS NULL 71 + |sql}] 72 + did 73 + end 74 + 75 + let generate_code () = 76 + let () = Mirage_crypto_rng_unix.use_default () in 77 + Multibase.Base32.encode_string ~pad:false 78 + (* 5 bytes = 8 base32 chars *) 79 + @@ Mirage_crypto_rng_unix.getrandom 5 80 + 81 + let format_code code = 82 + if String.length code = 8 then 83 + String.sub code 0 4 ^ "-" ^ String.sub code 4 4 84 + else code 85 + 86 + let normalize_code code = 87 + String.concat "" (String.split_on_char '-' code) |> String.uppercase_ascii 88 + 89 + let generate_codes () = List.init code_count (fun _ -> generate_code ()) 90 + 91 + let hash_code code = Bcrypt.hash code |> Bcrypt.string_of_hash 92 + 93 + let verify_code_hash code hash = 94 + try 95 + let hash_obj = Bcrypt.hash_of_string hash in 96 + Bcrypt.verify code hash_obj 97 + with _ -> false 98 + 99 + let store_codes ~did ~codes db = 100 + let now = Util.now_ms () in 101 + Lwt_list.iter_s 102 + (fun code -> 103 + let code_hash = hash_code code in 104 + Util.use_pool db 105 + @@ Queries.insert_backup_code ~did ~code_hash ~created_at:now ) 106 + codes 107 + 108 + let regenerate ~did db = 109 + let%lwt () = Util.use_pool db @@ Queries.delete_backup_codes_by_did ~did in 110 + let codes = generate_codes () in 111 + let%lwt () = store_codes ~did ~codes db in 112 + Lwt.return (List.map format_code codes) 113 + 114 + let verify_and_consume ~did ~code db = 115 + let normalized_code = normalize_code code in 116 + let%lwt codes = 117 + Util.use_pool db @@ Queries.get_unused_backup_codes_by_did ~did 118 + in 119 + let rec check = function 120 + | [] -> 121 + Lwt.return_false 122 + | c :: rest -> 123 + if verify_code_hash normalized_code c.code_hash then 124 + let now = Util.now_ms () in 125 + let%lwt () = 126 + Util.use_pool db 127 + @@ Queries.mark_code_used ~id:c.id ~did ~used_at:now 128 + in 129 + Lwt.return_true 130 + else check rest 131 + in 132 + check codes 133 + 134 + let get_remaining_count ~did db = 135 + Util.use_pool db @@ Queries.count_unused_backup_codes ~did 136 + 137 + let has_backup_codes ~did db = 138 + let%lwt count = get_remaining_count ~did db in 139 + Lwt.return (count > 0) 140 + 141 + let ensure_codes_exist ~did db = 142 + let%lwt count = get_remaining_count ~did db in 143 + if count > 0 then Lwt.return_none 144 + else 145 + let%lwt codes = regenerate ~did db in 146 + Lwt.return_some codes 147 + end 148 + 149 + module Queries = struct 150 + let set_totp_secret = 151 + [%rapper 152 + execute 153 + {sql| UPDATE actors SET totp_secret = %Blob{secret}, totp_verified_at = NULL 154 + WHERE did = %string{did} 155 + |sql}] 156 + 157 + let get_totp_secret did = 158 + [%rapper 159 + get_opt 160 + {sql| SELECT @Blob?{totp_secret}, @int?{totp_verified_at} 161 + FROM actors WHERE did = %string{did} 162 + |sql}] 163 + did 164 + 165 + let verify_totp_secret = 166 + [%rapper 167 + execute 168 + {sql| UPDATE actors SET totp_verified_at = %int{verified_at} 169 + WHERE did = %string{did} 170 + |sql}] 171 + 172 + let clear_totp_secret = 173 + [%rapper 174 + execute 175 + {sql| UPDATE actors SET totp_secret = NULL, totp_verified_at = NULL 176 + WHERE did = %string{did} 177 + |sql}] 178 + 179 + let is_totp_enabled did = 180 + [%rapper 181 + get_opt 182 + {sql| SELECT 1 AS @int{enabled} 183 + FROM actors WHERE did = %string{did} AND totp_verified_at IS NOT NULL 184 + |sql}] 185 + did 186 + end 187 + 188 + let generate_secret () = 189 + let () = Mirage_crypto_rng_unix.use_default () in 190 + Bytes.of_string (Mirage_crypto_rng_unix.getrandom secret_length) 191 + 192 + let make_provisioning_uri ~secret ~email ~issuer = 193 + let secret_b32 = 194 + Multibase.Base32.encode_exn ~pad:false (Bytes.to_string secret) 195 + in 196 + let encoded_email = Uri.pct_encode email in 197 + let encoded_issuer = Uri.pct_encode issuer in 198 + Printf.sprintf 199 + "otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=%d&period=%d" 200 + encoded_issuer encoded_email secret_b32 encoded_issuer code_digits time_step 201 + 202 + let hotp ~(secret : bytes) ~(counter : int64) : string = 203 + (* convert counter to 8-byte big-endian *) 204 + let counter_bytes = Bytes.create 8 in 205 + let c = ref counter in 206 + for i = 7 downto 0 do 207 + Bytes.set counter_bytes i (Char.chr (Int64.to_int (Int64.logand !c 0xffL))) ; 208 + c := Int64.shift_right_logical !c 8 209 + done ; 210 + let hmac = 211 + Digestif.SHA1.( 212 + hmac_bytes ~key:(Bytes.to_string secret) counter_bytes |> to_raw_string ) 213 + in 214 + (* dynamic truncation *) 215 + let offset = Char.code hmac.[19] land 0xf in 216 + let code = 217 + ((Char.code hmac.[offset] land 0x7f) lsl 24) 218 + lor ((Char.code hmac.[offset + 1] land 0xff) lsl 16) 219 + lor ((Char.code hmac.[offset + 2] land 0xff) lsl 8) 220 + lor (Char.code hmac.[offset + 3] land 0xff) 221 + in 222 + let modulo = int_of_float (10. ** float_of_int code_digits) in 223 + Printf.sprintf "%0*d" code_digits (code mod modulo) 224 + 225 + let generate_code ~secret = 226 + let counter = 227 + Int64.div (Int64.of_float (Unix.gettimeofday ())) (Int64.of_int time_step) 228 + in 229 + hotp ~secret ~counter 230 + 231 + let verify_code ~secret ~code = 232 + let current_counter = 233 + Int64.div (Int64.of_float (Unix.gettimeofday ())) (Int64.of_int time_step) 234 + in 235 + let rec check offset = 236 + if offset > window_size then false 237 + else 238 + let counter_plus = Int64.add current_counter (Int64.of_int offset) in 239 + let counter_minus = Int64.sub current_counter (Int64.of_int offset) in 240 + if hotp ~secret ~counter:counter_plus = code then true 241 + else if offset > 0 && hotp ~secret ~counter:counter_minus = code then true 242 + else check (offset + 1) 243 + in 244 + check 0 245 + 246 + let create_secret ~did ~secret db = 247 + Util.use_pool db @@ Queries.set_totp_secret ~did ~secret 248 + 249 + let get_secret ~did db = 250 + match%lwt Util.use_pool db @@ Queries.get_totp_secret ~did with 251 + | Some (Some secret, verified_at) -> 252 + Lwt.return_some (secret, verified_at) 253 + | _ -> 254 + Lwt.return_none 255 + 256 + let verify_and_enable ~did ~code db = 257 + match%lwt get_secret ~did db with 258 + | None -> 259 + Lwt.return_error "No TOTP setup in progress" 260 + | Some (_, Some _) -> 261 + Lwt.return_error "TOTP is already enabled" 262 + | Some (secret, None) -> 263 + if verify_code ~secret ~code then 264 + let now = Util.now_ms () in 265 + let%lwt () = 266 + Util.use_pool db @@ Queries.verify_totp_secret ~did ~verified_at:now 267 + in 268 + Lwt.return_ok () 269 + else Lwt.return_error "Invalid verification code" 270 + 271 + let disable ~did db = Util.use_pool db @@ Queries.clear_totp_secret ~did 272 + 273 + let is_enabled ~did db = 274 + match%lwt Util.use_pool db @@ Queries.is_totp_enabled ~did with 275 + | Some _ -> 276 + Lwt.return_true 277 + | None -> 278 + Lwt.return_false 279 + 280 + let verify_login_code ~did ~code db = 281 + match%lwt get_secret ~did db with 282 + | Some (secret, Some _) -> 283 + Lwt.return (verify_code ~secret ~code) 284 + | _ -> 285 + Lwt.return_false
+229
pegasus/lib/two_factor.ml
··· 1 + let pending_session_expiry_ms = 5 * 60 * 1000 2 + 3 + let email_code_expiry_ms = 10 * 60 * 1000 4 + 5 + module Types = struct 6 + type two_factor_method = TOTP | Email | BackupCode 7 + 8 + type two_factor_status = 9 + {totp_enabled: bool; email_2fa_enabled: bool; backup_codes_remaining: int} 10 + [@@deriving yojson {strict= false}] 11 + 12 + type pending_2fa = 13 + { id: int 14 + ; session_token: string 15 + ; did: string 16 + ; password_verified_at: int 17 + ; expires_at: int 18 + ; email_code: string option 19 + ; email_code_expires_at: int option 20 + ; created_at: int } 21 + 22 + type available_methods = Frontend.LoginPage.two_fa_methods = 23 + {totp: bool; email: bool; backup_code: bool} 24 + [@@deriving yojson {strict= false}] 25 + end 26 + 27 + open Types 28 + 29 + module Queries = struct 30 + let insert_pending_2fa = 31 + [%rapper 32 + execute 33 + {sql| INSERT INTO pending_2fa (session_token, did, password_verified_at, expires_at, created_at) 34 + VALUES (%string{session_token}, %string{did}, %int{password_verified_at}, %int{expires_at}, %int{created_at}) 35 + |sql}] 36 + 37 + let get_pending_2fa session_token now = 38 + [%rapper 39 + get_opt 40 + {sql| SELECT @int{id}, @string{session_token}, @string{did}, @int{password_verified_at}, 41 + @int{expires_at}, @string?{email_code}, @int?{email_code_expires_at}, @int{created_at} 42 + FROM pending_2fa WHERE session_token = %string{session_token} AND expires_at > %int{now} 43 + |sql} 44 + record_out] 45 + ~session_token ~now 46 + 47 + let get_pending_2fa_for_did did now = 48 + [%rapper 49 + get_opt 50 + {sql| SELECT @int{id}, @string{session_token}, @string{did}, @int{password_verified_at}, 51 + @int{expires_at}, @string?{email_code}, @int?{email_code_expires_at}, @int{created_at} 52 + FROM pending_2fa WHERE did = %string{did} AND expires_at > %int{now} 53 + |sql} 54 + record_out] 55 + ~did ~now 56 + 57 + let update_email_code = 58 + [%rapper 59 + execute 60 + {sql| UPDATE pending_2fa SET email_code = %string{email_code}, email_code_expires_at = %int{email_code_expires_at} 61 + WHERE session_token = %string{session_token} 62 + |sql}] 63 + 64 + let delete_pending_2fa = 65 + [%rapper 66 + execute 67 + {sql| DELETE FROM pending_2fa WHERE session_token = %string{session_token} 68 + |sql}] 69 + 70 + let get_email_2fa_enabled did = 71 + [%rapper 72 + get_opt 73 + {sql| SELECT @int{email_2fa_enabled} FROM actors WHERE did = %string{did} 74 + |sql}] 75 + did 76 + 77 + let set_email_2fa_enabled = 78 + [%rapper 79 + execute 80 + {sql| UPDATE actors SET email_2fa_enabled = %int{enabled} 81 + WHERE did = %string{did} 82 + |sql}] 83 + 84 + let is_2fa_enabled = 85 + [%rapper 86 + get_opt 87 + {sql| SELECT CASE 88 + WHEN totp_verified_at IS NOT NULL OR email_2fa_enabled = 1 THEN 1 89 + ELSE 0 90 + END AS @int{result} 91 + FROM actors 92 + WHERE did = %string{did} |sql}] 93 + end 94 + 95 + let generate_session_token () = 96 + let () = Mirage_crypto_rng_unix.use_default () in 97 + let token = Mirage_crypto_rng_unix.getrandom 32 in 98 + Base64.(encode_string ~alphabet:uri_safe_alphabet ~pad:false token) 99 + 100 + let is_2fa_enabled ~did db = 101 + match%lwt Util.use_pool db @@ Queries.is_2fa_enabled ~did with 102 + | Some 1 -> 103 + Lwt.return_true 104 + | _ -> 105 + Lwt.return_false 106 + 107 + let get_status ~did db = 108 + let%lwt totp_enabled = Totp.is_enabled ~did db in 109 + let%lwt email_2fa = 110 + match%lwt Util.use_pool db @@ Queries.get_email_2fa_enabled ~did with 111 + | Some 1 -> 112 + Lwt.return_true 113 + | _ -> 114 + Lwt.return_false 115 + in 116 + let%lwt backup_count = Totp.Backup_codes.get_remaining_count ~did db in 117 + Lwt.return 118 + { totp_enabled 119 + ; email_2fa_enabled= email_2fa 120 + ; backup_codes_remaining= backup_count } 121 + 122 + let get_available_methods ~did db = 123 + let%lwt totp_enabled = Totp.is_enabled ~did db in 124 + let%lwt email_2fa = 125 + match%lwt Util.use_pool db @@ Queries.get_email_2fa_enabled ~did with 126 + | Some 1 -> 127 + Lwt.return_true 128 + | _ -> 129 + Lwt.return_false 130 + in 131 + let%lwt has_backup = Totp.Backup_codes.has_backup_codes ~did db in 132 + Lwt.return {totp= totp_enabled; email= email_2fa; backup_code= has_backup} 133 + 134 + (* create a pending 2FA session after password verification *) 135 + let create_pending_session ~did db = 136 + let session_token = generate_session_token () in 137 + let now = Util.now_ms () in 138 + let expires_at = now + pending_session_expiry_ms in 139 + let%lwt () = 140 + Util.use_pool db 141 + @@ Queries.insert_pending_2fa ~session_token ~did ~password_verified_at:now 142 + ~expires_at ~created_at:now 143 + in 144 + Lwt.return session_token 145 + 146 + let get_pending_session ~session_token db = 147 + let now = Util.now_ms () in 148 + Util.use_pool db @@ Queries.get_pending_2fa session_token now 149 + 150 + let get_pending_session_for_did ~did db = 151 + let now = Util.now_ms () in 152 + Util.use_pool db @@ Queries.get_pending_2fa_for_did did now 153 + 154 + let delete_pending_session ~session_token db = 155 + Util.use_pool db @@ Queries.delete_pending_2fa ~session_token 156 + 157 + let send_email_code ~session_token ~actor db = 158 + let code = Util.make_code () in 159 + let now = Util.now_ms () in 160 + let expires_at = now + email_code_expiry_ms in 161 + let%lwt () = 162 + Util.use_pool db 163 + @@ Queries.update_email_code ~session_token ~email_code:code 164 + ~email_code_expires_at:expires_at 165 + in 166 + let subject = "Your login verification code" in 167 + let body = 168 + Emails.TwoFactorAuth.make ~handle:actor.Data_store.Types.handle ~code 169 + in 170 + let recipients = [Letters.To actor.email] in 171 + let%lwt () = Util.send_email_or_log ~recipients ~subject ~body in 172 + Lwt.return_unit 173 + 174 + let _verify_email_code ~code ~session = 175 + match (session.email_code, session.email_code_expires_at) with 176 + | Some stored_code, Some expires_at -> 177 + let now = Util.now_ms () in 178 + if now > expires_at then Lwt.return_error "Email code expired" 179 + else if stored_code = code then Lwt.return_ok session.did 180 + else Lwt.return_error "Invalid code" 181 + | _ -> 182 + Lwt.return_error "No email code sent for this session" 183 + 184 + let verify_email_code_by_token ~session_token ~code db = 185 + match%lwt get_pending_session ~session_token db with 186 + | None -> 187 + Lwt.return_error "Invalid or expired session" 188 + | Some pending -> 189 + _verify_email_code ~code ~session:pending 190 + 191 + let verify_email_code_by_did ~did ~code db = 192 + match%lwt get_pending_session_for_did ~did db with 193 + | None -> 194 + Lwt.return_error "Invalid or expired session" 195 + | Some pending -> 196 + _verify_email_code ~code ~session:pending 197 + 198 + let verify_totp_code ~session_token ~code db = 199 + match%lwt get_pending_session ~session_token db with 200 + | None -> 201 + Lwt.return_error "Invalid or expired session" 202 + | Some pending -> 203 + let%lwt valid = Totp.verify_login_code ~did:pending.did ~code db in 204 + if valid then Lwt.return_ok pending.did 205 + else Lwt.return_error "Invalid TOTP code" 206 + 207 + let verify_backup_code ~session_token ~code db = 208 + match%lwt get_pending_session ~session_token db with 209 + | None -> 210 + Lwt.return_error "Invalid or expired session" 211 + | Some pending -> 212 + let%lwt valid = 213 + Totp.Backup_codes.verify_and_consume ~did:pending.did ~code db 214 + in 215 + if valid then Lwt.return_ok pending.did 216 + else Lwt.return_error "Invalid backup code" 217 + 218 + let enable_email_2fa ~did db = 219 + Util.use_pool db @@ Queries.set_email_2fa_enabled ~did ~enabled:1 220 + 221 + let disable_email_2fa ~did db = 222 + Util.use_pool db @@ Queries.set_email_2fa_enabled ~did ~enabled:0 223 + 224 + let is_email_2fa_enabled ~did db = 225 + match%lwt Util.use_pool db @@ Queries.get_email_2fa_enabled ~did with 226 + | Some 1 -> 227 + Lwt.return_true 228 + | _ -> 229 + Lwt.return_false
+2 -1
pegasus/lib/util.ml
··· 475 475 let make_code () = 476 476 let () = Mirage_crypto_rng_unix.use_default () in 477 477 let token = 478 - Multibase.Base32.encode_string @@ Mirage_crypto_rng_unix.getrandom 32 478 + Multibase.Base32.encode_string ~pad:false 479 + @@ Mirage_crypto_rng_unix.getrandom 8 479 480 in 480 481 String.sub token 0 5 ^ "-" ^ String.sub token 5 5 481 482
+12
pnpm-lock.yaml
··· 14 14 '@simplewebauthn/browser': 15 15 specifier: ^11.0.0 16 16 version: 11.0.0 17 + qrcode.react: 18 + specifier: ^4.2.0 19 + version: 4.2.0(react@19.2.0) 17 20 react: 18 21 specifier: ^19.2.0 19 22 version: 19.2.0 ··· 1023 1026 postcss@8.5.6: 1024 1027 resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} 1025 1028 engines: {node: ^10 || ^12 || >=14} 1029 + 1030 + qrcode.react@4.2.0: 1031 + resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} 1032 + peerDependencies: 1033 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 1026 1034 1027 1035 react-aria-components@1.13.0: 1028 1036 resolution: {integrity: sha512-t1mm3AVy/MjUJBZ7zrb+sFC5iya8Vvw3go3mGKtTm269bXGZho7BLA4IgT+0nOS3j+ku6ChVi8NEoQVFoYzJJA==} ··· 2467 2475 nanoid: 3.3.11 2468 2476 picocolors: 1.1.1 2469 2477 source-map-js: 1.2.1 2478 + 2479 + qrcode.react@4.2.0(react@19.2.0): 2480 + dependencies: 2481 + react: 19.2.0 2470 2482 2471 2483 react-aria-components@1.13.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0): 2472 2484 dependencies: