backup-codes can be created
authorPhiTux <redacted>
Mon, 21 Jul 2025 16:03:14 +0000 (18:03 +0200)
committerPhiTux <redacted>
Mon, 21 Jul 2025 16:03:14 +0000 (18:03 +0200)
backend/handlers/users.go
backend/main.go
backend/utils/file_handling.go
backend/utils/security.go
frontend/src/routes/(authed)/+layout.svelte

index c84ddd17c276a60c7db8f4a10db01a852b06c248..645cad7596bc586bdc9fcae822b1391b3c5c436e 100644 (file)
@@ -652,7 +652,7 @@ func ChangePassword(w http.ResponseWriter, r *http.Request) {
        // Get user ID from context
        userID, ok := r.Context().Value(utils.UserIDKey).(int)
        if !ok {
-               utils.JSONResponse(w, http.StatusOK, map[string]any{
+               utils.JSONResponse(w, http.StatusUnauthorized, map[string]any{
                        "success": false,
                        "message": "User not authenticated",
                })
@@ -662,7 +662,7 @@ func ChangePassword(w http.ResponseWriter, r *http.Request) {
        // Get derived key from context
        derivedKey, ok := r.Context().Value(utils.DerivedKeyKey).(string)
        if !ok {
-               utils.JSONResponse(w, http.StatusOK, map[string]any{
+               utils.JSONResponse(w, http.StatusUnauthorized, map[string]any{
                        "success": false,
                        "message": "User not authenticated",
                })
@@ -865,7 +865,7 @@ func DeleteAccount(w http.ResponseWriter, r *http.Request) {
        // Get user ID from context
        userID, ok := r.Context().Value(utils.UserIDKey).(int)
        if !ok {
-               utils.JSONResponse(w, http.StatusOK, map[string]any{
+               utils.JSONResponse(w, http.StatusUnauthorized, map[string]any{
                        "success": false,
                        "message": "User not authenticated",
                })
@@ -975,3 +975,86 @@ func DeleteAccount(w http.ResponseWriter, r *http.Request) {
                "success": true,
        })
 }
+
+type CreateBackupCodesRequest struct {
+       Password string `json:"password"`
+}
+
+// CreateBackupCodes creates 6 random backup codes for the user
+// Those are storing the encrypted derived key from the original password!
+func CreateBackupCodes(w http.ResponseWriter, r *http.Request) {
+       // Get user ID from context
+       userID, ok := r.Context().Value(utils.UserIDKey).(int)
+       if !ok {
+               utils.JSONResponse(w, http.StatusUnauthorized, map[string]any{
+                       "success": false,
+                       "message": "User not authenticated",
+               })
+               return
+       }
+
+       // Parse request body
+       var req CreateBackupCodesRequest
+       if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+               utils.JSONResponse(w, http.StatusBadRequest, map[string]any{
+                       "success": false,
+                       "message": "Invalid request body",
+               })
+               return
+       }
+
+       // Check if password is correct
+       correct, backupCodes, err := utils.CheckPasswordForUser(userID, req.Password)
+       if err != nil {
+               utils.Logger.Printf("Error checking password for user %d: %v", userID, err)
+
+               utils.JSONResponse(w, http.StatusOK, map[string]any{
+                       "success": false,
+                       "message": err,
+               })
+               return
+       } else if !correct {
+               utils.JSONResponse(w, http.StatusOK, map[string]any{
+                       "success":            false,
+                       "message":            "Password is incorrect",
+                       "password_incorrect": true,
+               })
+               return
+       }
+       // otherwise, we have the correct password
+
+       // Get derived key from context
+       derivedKey, ok := r.Context().Value(utils.DerivedKeyKey).(string)
+       if !ok {
+               utils.JSONResponse(w, http.StatusUnauthorized, map[string]any{
+                       "success": false,
+                       "message": "User not authenticated",
+               })
+               return
+       }
+
+       // Generate backup codes
+       codes, codeData, err := utils.GenerateBackupCodes(derivedKey)
+       if err != nil {
+               utils.JSONResponse(w, http.StatusInternalServerError, map[string]any{
+                       "success": false,
+                       "message": fmt.Sprintf("Error generating backup codes: %v", err),
+               })
+               return
+       }
+
+       // Save backup codes to file
+       if err := utils.SaveBackupCodes(userID, codeData); err != nil {
+               utils.JSONResponse(w, http.StatusInternalServerError, map[string]any{
+                       "success": false,
+                       "message": fmt.Sprintf("Error saving backup codes: %v", err),
+               })
+               return
+       }
+
+       utils.JSONResponse(w, http.StatusOK, map[string]any{
+               "success":                true,
+               "backup_codes":           codes,
+               "available_backup_codes": backupCodes,
+       })
+}
index d308d8109ca3d953ae0a27cc736511977db78c3f..a230083c02d79454497f91d3d415376562c0ac9b 100644 (file)
@@ -41,6 +41,7 @@ func main() {
        mux.HandleFunc("POST /users/saveUserSettings", middleware.RequireAuth(handlers.SaveUserSettings))
        mux.HandleFunc("POST /users/changePassword", middleware.RequireAuth(handlers.ChangePassword))
        mux.HandleFunc("POST /users/deleteAccount", middleware.RequireAuth(handlers.DeleteAccount))
+       mux.HandleFunc("POST /users/createBackupCodes", middleware.RequireAuth(handlers.CreateBackupCodes))
 
        mux.HandleFunc("POST /logs/saveLog", middleware.RequireAuth(handlers.SaveLog))
        mux.HandleFunc("GET /logs/getLog", middleware.RequireAuth(handlers.GetLog))
index 979bd82e6536bf416f1ee1d7834d9d1244167a2a..54ddaf5a90d51dc06494491ae3814ebda5f4a7c0 100644 (file)
@@ -458,3 +458,44 @@ func DeleteUserData(userID int) error {
 
        return nil
 }
+
+// saves the hash, salt and encrypted derived key of the backup codes to the users.json file
+func SaveBackupCodes(userID int, codes []map[string]any) error {
+       // Get the current users
+       users, err := GetUsers()
+       if err != nil {
+               return fmt.Errorf("error getting users: %v", err)
+       }
+
+       // Find the user with the given ID in the users array
+       usersList, ok := users["users"].([]any)
+       if !ok {
+               return fmt.Errorf("invalid users format, 'users' is not an array")
+       }
+
+       var foundUser map[string]any
+       for _, u := range usersList {
+               uMap, ok := u.(map[string]any)
+               if !ok {
+                       continue
+               }
+               if id, ok := uMap["user_id"].(float64); ok && int(id) == userID {
+                       foundUser = uMap
+                       break
+               }
+       }
+
+       if foundUser == nil {
+               return fmt.Errorf("user with ID %d does not exist", userID)
+       }
+
+       // Save the backup codes to the user's data
+       foundUser["backup_codes"] = codes
+
+       // Write the updated users back to the file
+       if err := WriteUsers(users); err != nil {
+               return fmt.Errorf("error writing users: %v", err)
+       }
+
+       return nil
+}
index 4c497696cb804b347df54b55658a0b1a781ee25e..da535feac64ead3bf1a14d6ea8d1b5e911951a7c 100644 (file)
@@ -7,6 +7,7 @@ import (
        "encoding/base64"
        "fmt"
        "io"
+       "math/big"
        "runtime"
        "strings"
        "time"
@@ -85,14 +86,18 @@ type Argon2Configuration struct {
        KeyLength  uint32
 }
 
-// HashPassword hashes a password using Argon2
-func HashPassword(password string) (string, error) {
-       config := &Argon2Configuration{
+func getArgon2Configuration() *Argon2Configuration {
+       return &Argon2Configuration{
                TimeCost:   5,
                MemoryCost: 64 * 1024,
                Threads:    uint8(runtime.NumCPU()),
                KeyLength:  32,
        }
+}
+
+// HashPassword hashes a password using Argon2
+func HashPassword(password string) (string, error) {
+       config := getArgon2Configuration()
 
        // Generate a random salt
        salt := make([]byte, 16)
@@ -187,7 +192,7 @@ func DeriveKeyFromPassword(password, saltBase64 string) ([]byte, error) {
                return nil, err
        }
 
-       // Derive key
+       // Derive key (don't use config from above, as a variable amount of thread will lead to different results)
        key := argon2.IDKey([]byte(password), salt, 2, 64*1024, 4, 32)
        return key, nil
 }
@@ -403,3 +408,120 @@ func GetEncryptionKey(userID int, derivedKey string) (string, error) {
 
        return "", fmt.Errorf("user not found")
 }
+
+// CheckPasswordForUser checks if the provided password matches the user's password OR on of his backup codes
+// Returns true if the password matches, false otherwise
+// Return the amount of backup codes available for the user (-1 if password does not match)
+func CheckPasswordForUser(userID int, password string) (bool, int, error) {
+       // Get users
+       users, err := GetUsers()
+       if err != nil {
+               return false, -1, fmt.Errorf("error retrieving users: %v", err)
+       }
+
+       // Find user
+       usersList, ok := users["users"].([]any)
+       if !ok {
+               return false, -1, fmt.Errorf("users.json is not in the correct format")
+       }
+
+       for _, u := range usersList {
+               user, ok := u.(map[string]any)
+               if !ok {
+                       continue
+               }
+
+               if id, ok := user["user_id"].(float64); ok && int(id) == userID {
+                       passwordHash, ok := user["password"].(string)
+                       if !ok {
+                               return false, -1, fmt.Errorf("user data is not in the correct format")
+                       }
+
+                       if VerifyPassword(password, passwordHash) {
+                               return true, -1, nil
+                       }
+
+                       // Check backup codes
+                       backupCodes, ok := user["backup_codes"].([]any)
+                       if !ok {
+                               return false, -1, fmt.Errorf("user backup codes are not in the correct format")
+                       }
+
+                       for _, code := range backupCodes {
+                               codeStr, ok := code.(string)
+                               if !ok {
+                                       continue // Skip invalid codes
+                               }
+                               if VerifyPassword(password, codeStr) {
+                                       return true, -1, nil
+                               }
+                       }
+
+                       return false, -1, nil // Password does not match
+               }
+       }
+
+       return false, -1, fmt.Errorf("user not found")
+}
+
+func CreatePasswordString() string {
+       var chars string = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/+-_*!?#$%&(){}[]=@~"
+       password := make([]byte, 10)
+
+       for i := range password {
+               n, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
+               if err != nil {
+                       panic(fmt.Sprintf("Failed to generate random index: %v", err))
+               }
+               password[i] = chars[n.Int64()]
+       }
+
+       return string(password)
+}
+
+// GenerateBackupCodes generates 6 backup codes for a user
+// With those backup-codes, the derived key gets encrypted
+func GenerateBackupCodes(derived_key string) ([]string, []map[string]any, error) {
+       backupCodes := make([]string, 6)
+       codeData := make([]map[string]any, 6)
+       for i := range 6 {
+               // Initialize the map for this index
+               codeData[i] = make(map[string]any)
+
+               // Generate a random backup code (=password (!= uuid))
+               code := CreatePasswordString()
+
+               // create hash
+               hash, err := HashPassword(code)
+               if err != nil {
+                       return nil, nil, fmt.Errorf("error hashing backup code: %v", err)
+               }
+
+               // Generate a random salt
+               salt := make([]byte, 16)
+               if _, err := rand.Read(salt); err != nil {
+                       return nil, nil, fmt.Errorf("error generating salt: %v", err)
+               }
+               // Convert salt to base64
+               saltBase64 := base64.StdEncoding.EncodeToString(salt)
+
+               // Create derived encryption key to later encrypt the original derived key
+               intermediateKey, err := DeriveKeyFromPassword(code, saltBase64)
+               if err != nil {
+                       return nil, nil, fmt.Errorf("error deriving key from password: %v", err)
+               }
+
+               // Encrypt the derived key with the intermediate key from the backup code
+               encDerivedKey, err := EncryptText(derived_key, base64.URLEncoding.EncodeToString(intermediateKey))
+               if err != nil {
+                       return nil, nil, fmt.Errorf("error encrypting derived key: %v", err)
+               }
+
+               backupCodes[i] = code
+               codeData[i]["password"] = hash
+               codeData[i]["salt"] = saltBase64
+               codeData[i]["enc_derived_key"] = encDerivedKey
+       }
+
+       return backupCodes, codeData, nil
+}
index 503da4869efadeb8447eb01751fd878265ac37e5..a233d56e888ce334ee2a7edc5077ef51b7e5837d 100644 (file)
@@ -22,7 +22,9 @@
                faPencil,
                faSliders,
                faTriangleExclamation,
-               faTrash
+               faTrash,
+               faCopy,
+               faCheck
        } from '@fortawesome/free-solid-svg-icons';
        import Tag from '$lib/Tag.svelte';
        import SelectTimezone from '$lib/SelectTimezone.svelte';
                                });
                        }, 400);
                });
+
+               document.getElementById('settingsModal').addEventListener('hidden.bs.modal', function () {
+                       backupCodes = [];
+               });
        });
 
        function logout(errorCode) {
                                deleteAccountPassword = '';
                        });
        }
+
+       let backupCodesPassword = $state('');
+       let isGeneratingBackupCodes = $state(false);
+       let backupCodes = $state([]);
+       let showBackupCodesPasswordIncorrect = $state(false);
+       let codesCopiedSuccess = $state(false);
+       let showBackupCodesError = $state(false);
+
+       function createBackupCodes() {
+               if (isGeneratingBackupCodes) return;
+               isGeneratingBackupCodes = true;
+
+               showBackupCodesPasswordIncorrect = false;
+               showBackupCodesError = false;
+               backupCodes = [];
+
+               axios
+                       .post(API_URL + '/users/createBackupCodes', {
+                               password: backupCodesPassword
+                       })
+                       .then((response) => {
+                               if (response.data.success) {
+                                       backupCodes = response.data.backup_codes;
+                               } else if (response.data.password_incorrect) {
+                                       console.error('Error creating backup codes: Password incorrect');
+                                       showBackupCodesPasswordIncorrect = true;
+                               } else {
+                                       console.error('Error creating backup codes');
+                                       console.error(response.data);
+                                       showBackupCodesError = true;
+                               }
+                       })
+                       .catch((error) => {
+                               console.error(error);
+                               const toast = new bootstrap.Toast(document.getElementById('toastErrorCreateBackupCodes'));
+                               toast.show();
+                       })
+                       .finally(() => {
+                               isGeneratingBackupCodes = false;
+                       });
+       }
+
+       function copyBackupCodes() {
+               if (backupCodes.length === 0) return;
+
+               const codesText = backupCodes.join('\n');
+               navigator.clipboard.writeText(codesText).then(
+                       () => {
+                               // Show success checkmark for 3 seconds
+                               codesCopiedSuccess = true;
+                               setTimeout(() => {
+                                       codesCopiedSuccess = false;
+                               }, 3000);
+                       },
+                       (err) => {
+                               console.error('Failed to copy backup codes: ', err);
+                       }
+               );
+       }
 </script>
 
 <div class="d-flex flex-column h-100">
                                                                <h3 class="text-primary">#️⃣ Tags</h3>
                                                                <div>
                                                                        Hier können Tags bearbeitet oder auch vollständig aus DailyTxT gelöscht werden.
+                                                                       {#if $tags.length === 0}
+                                                                               <div class="alert alert-info my-2" role="alert">
+                                                                                       Es sind noch keine Tags vorhanden. Erstelle einen neuen Tag im Schreibmodus.
+                                                                               </div>
+                                                                       {/if}
                                                                        <div class="d-flex flex-column tagColumn mt-1">
                                                                                {#each $tags as tag}
                                                                                        <Tag
 
                                                        <div id="data">
                                                                <h3 class="text-primary">📁 Daten</h3>
-                                                               <div id="export"><h5>Export</h5></div>
-                                                               <div id="import"><h5>Import</h5></div>
+                                                               <div><h5>Export</h5></div>
+                                                               <div><h5>Import</h5></div>
                                                        </div>
 
                                                        <div id="security">
                                                                <h3 class="text-primary">🔒 Sicherheit</h3>
-                                                               <div id="password">
+                                                               <div>
                                                                        <h5>Password ändern</h5>
                                                                        <form onsubmit={changePassword}>
                                                                                <div class="form-floating mb-3">
                                                                        {#if changingPasswordSuccess}
                                                                                <div class="alert alert-success mt-2" role="alert" transition:slide>
                                                                                        Das Passwort wurde erfolgreich geändert!<br />
-                                                                                       Backup-Keys wurden ungültig gemacht (sofern vorhanden), und müssen neu erstellt
+                                                                                       Backup-Codes wurden ungültig gemacht (sofern vorhanden), und müssen neu erstellt
                                                                                        werden.
                                                                                </div>
                                                                        {/if}
                                                                                </div>
                                                                        {/if}
                                                                </div>
-                                                               <div id="backupkeys"><h5>Backup-Keys</h5></div>
-                                                               <div id="username"><h5>Username ändern</h5></div>
-                                                               <div id="deleteaccount">
+                                                               <div>
+                                                                       <h5>Backup-Codes</h5>
+                                                                       <ul>
+                                                                               <li>
+                                                                                       Backup-Codes funktionieren wie Einmal-Passwörter. Sie können immer anstelle
+                                                                                       des Passworts verwendet werden, allerdings sind sie jeweils nur einmal gültig
+                                                                                       und werden anschließend gelöscht.
+                                                                               </li>
+                                                                               <li>
+                                                                                       Es werden immer 6 Codes generiert, welche die vorherigen Codes (sofern
+                                                                                       vorhanden) ersetzen.
+                                                                               </li>
+                                                                               <li>
+                                                                                       Du musst dir die Codes nach der Erstellung direkt notieren, sie können nicht
+                                                                                       erneut angezeigt werden!
+                                                                               </li>
+                                                                       </ul>
+
+                                                                       <form onsubmit={createBackupCodes}>
+                                                                               <div class="form-floating mb-3">
+                                                                                       <input
+                                                                                               type="password"
+                                                                                               class="form-control"
+                                                                                               id="currentPassword"
+                                                                                               placeholder="Aktuelles Passwort"
+                                                                                               bind:value={backupCodesPassword}
+                                                                                       />
+                                                                                       <label for="currentPassword">Passwort bestätigen</label>
+                                                                               </div>
+                                                                               <button
+                                                                                       class="btn btn-primary"
+                                                                                       onclick={createBackupCodes}
+                                                                                       data-sveltekit-noscroll
+                                                                               >
+                                                                                       Backup-Codes generieren
+                                                                                       {#if isGeneratingBackupCodes}
+                                                                                               <!-- svelte-ignore a11y_no_static_element_interactions -->
+                                                                                               <div class="spinner-border" role="status">
+                                                                                                       <span class="visually-hidden">Loading...</span>
+                                                                                               </div>
+                                                                                       {/if}
+                                                                               </button>
+                                                                       </form>
+                                                                       {#if showBackupCodesPasswordIncorrect}
+                                                                               <div class="alert alert-danger mt-2" role="alert" transition:slide>
+                                                                                       Das eingegebene Passwort ist falsch!
+                                                                               </div>
+                                                                       {/if}
+                                                                       {#if backupCodes.length > 0}
+                                                                               <div class="alert alert-success alert-dismissible mt-3" transition:slide>
+                                                                                       <h6>Deine Backup-Codes:</h6>
+                                                                                       Notiere dir die Codes, können nach dem Schließen dieses Fenstern nicht erneut angezeigt
+                                                                                       werden!
+                                                                                       <button class="btn btn-secondary my-2" onclick={copyBackupCodes}>
+                                                                                               <Fa icon={codesCopiedSuccess ? faCheck : faCopy} />
+                                                                                               Codes kopieren
+                                                                                       </button>
+                                                                                       <ul class="list-group">
+                                                                                               {#each backupCodes as code}
+                                                                                                       <li class="list-group-item backupCode">
+                                                                                                               <code>{code}</code>
+                                                                                                       </li>
+                                                                                               {/each}
+                                                                                       </ul>
+                                                                               </div>
+                                                                       {/if}
+                                                                       {#if showBackupCodesError}
+                                                                               <div class="alert alert-danger mt-2" role="alert" transition:slide>
+                                                                                       Fehler beim Erstellen der Backup-Codes!
+                                                                               </div>
+                                                                       {/if}
+                                                               </div>
+                                                               <div><h5>Username ändern</h5></div>
+                                                               <div>
                                                                        <h5>Konto löschen</h5>
                                                                        <p>
                                                                                Dies löscht dein Konto und alle damit verbundenen Daten. Dies kann nicht
 </div>
 
 <style>
+       .backupCode {
+               font-size: 15pt;
+       }
+
        .footer-unsaved-changes {
                background-color: orange;
                color: black;
git clone https://git.99rst.org/PROJECT