From: PhiTux Date: Mon, 21 Jul 2025 16:03:14 +0000 (+0200) Subject: backup-codes can be created X-Git-Url: http://git.99rst.org/?a=commitdiff_plain;h=3ca129d425c62024e4df04055e4891c4fe712166;p=DailyTxT.git backup-codes can be created --- diff --git a/backend/handlers/users.go b/backend/handlers/users.go index c84ddd1..645cad7 100644 --- a/backend/handlers/users.go +++ b/backend/handlers/users.go @@ -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, + }) +} diff --git a/backend/main.go b/backend/main.go index d308d81..a230083 100644 --- a/backend/main.go +++ b/backend/main.go @@ -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)) diff --git a/backend/utils/file_handling.go b/backend/utils/file_handling.go index 979bd82..54ddaf5 100644 --- a/backend/utils/file_handling.go +++ b/backend/utils/file_handling.go @@ -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 +} diff --git a/backend/utils/security.go b/backend/utils/security.go index 4c49769..da535fe 100644 --- a/backend/utils/security.go +++ b/backend/utils/security.go @@ -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 +} diff --git a/frontend/src/routes/(authed)/+layout.svelte b/frontend/src/routes/(authed)/+layout.svelte index 503da48..a233d56 100644 --- a/frontend/src/routes/(authed)/+layout.svelte +++ b/frontend/src/routes/(authed)/+layout.svelte @@ -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'; @@ -70,6 +72,10 @@ }); }, 400); }); + + document.getElementById('settingsModal').addEventListener('hidden.bs.modal', function () { + backupCodes = []; + }); }); function logout(errorCode) { @@ -519,6 +525,65 @@ 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); + } + ); + }
@@ -872,6 +937,11 @@

#️⃣ Tags

Hier können Tags bearbeitet oder auch vollständig aus DailyTxT gelöscht werden. + {#if $tags.length === 0} + + {/if}
{#each $tags as tag}

📁 Daten

-
Export
-
Import
+
Export
+
Import

🔒 Sicherheit

-
+
Password ändern
@@ -1080,7 +1150,7 @@ {#if changingPasswordSuccess} {/if} @@ -1094,9 +1164,80 @@
{/if}
-
Backup-Keys
-
Username ändern
-
+
+
Backup-Codes
+
    +
  • + 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. +
  • +
  • + Es werden immer 6 Codes generiert, welche die vorherigen Codes (sofern + vorhanden) ersetzen. +
  • +
  • + Du musst dir die Codes nach der Erstellung direkt notieren, sie können nicht + erneut angezeigt werden! +
  • +
+ + +
+ + +
+ + + {#if showBackupCodesPasswordIncorrect} + + {/if} + {#if backupCodes.length > 0} +
+
Deine Backup-Codes:
+ Notiere dir die Codes, können nach dem Schließen dieses Fenstern nicht erneut angezeigt + werden! + +
    + {#each backupCodes as code} +
  • + {code} +
  • + {/each} +
+
+ {/if} + {#if showBackupCodesError} + + {/if} +
+
Username ändern
+
Konto löschen

Dies löscht dein Konto und alle damit verbundenen Daten. Dies kann nicht @@ -1344,6 +1485,10 @@