Reset Passwort funktioniert

This commit is contained in:
2025-08-13 08:28:47 +02:00
parent 1919d540fb
commit 9b7059884b
9 changed files with 598 additions and 6 deletions
+6
View File
@@ -10,6 +10,12 @@ func BaseRouterGrp(r *gin.Engine) *gin.RouterGroup {
// Routes
r.POST("/register", user.RegisterUser)
r.POST("/login", user.LoginUser)
// Password Reset Routes (unprotected)
r.POST("/password-reset/request", user.RequestPasswordReset)
r.GET("/password-reset/validate/:token", user.ValidateResetToken)
r.POST("/password-reset/reset", user.ResetPassword)
protected := r.Group("/api")
protected.Use(user.AuthMiddleware())
return protected
+193
View File
@@ -8,6 +8,7 @@ package user
import (
"bamort/logger"
"crypto/md5"
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
@@ -199,3 +200,195 @@ func AuthMiddleware() gin.HandlerFunc {
c.Next()
}
}
// generateResetHash generiert einen sicheren Hash für Password-Reset
func generateResetHash() (string, error) {
bytes := make([]byte, 32)
_, err := rand.Read(bytes)
if err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
// sendResetEmail simuliert das Senden einer E-Mail (hier nur Logging)
// In einer echten Implementierung würde hier ein E-Mail-Service verwendet
func sendResetEmail(email, username, resetHash, frontendURL string) error {
// Verwende die mitgegebene Frontend-URL oder fallback auf Standard
baseURL := frontendURL
if baseURL == "" {
baseURL = "http://localhost:3000" // Fallback, sollte aber nicht verwendet werden
}
resetLink := fmt.Sprintf("%s/reset-password?token=%s", baseURL, resetHash)
logger.Info("=== PASSWORD RESET EMAIL ===")
logger.Info("An: %s", email)
logger.Info("Betreff: Passwort zurücksetzen für %s", username)
logger.Info("Nachricht:")
logger.Info("Hallo %s,", username)
logger.Info("")
logger.Info("Sie haben eine Passwort-Zurücksetzung angefordert.")
logger.Info("Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen:")
logger.Info("")
logger.Info("%s", resetLink)
logger.Info("")
logger.Info("Dieser Link ist 14 Tage gültig.")
logger.Info("Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.")
logger.Info("")
logger.Info("=== END EMAIL ===")
// TODO: Hier echte E-Mail-Integration hinzufügen
// z.B. SendGrid, SMTP, etc.
return nil
}
// RequestPasswordReset Handler für Passwort-Reset-Anfrage
func RequestPasswordReset(c *gin.Context) {
logger.Debug("Starte Passwort-Reset-Anfrage...")
var input struct {
Email string `json:"email" binding:"required,email"`
RedirectURL string `json:"redirect_url,omitempty"` // Optionale Frontend-URL
}
if err := c.ShouldBindJSON(&input); err != nil {
logger.Error("Fehler beim Parsen der Reset-Anfrage: %s", err.Error())
respondWithError(c, http.StatusBadRequest, "Gültige E-Mail-Adresse erforderlich")
return
}
// Frontend-URL aus Request verwenden
redirectURL := input.RedirectURL
if redirectURL == "" {
// Fallback, sollte aber nicht verwendet werden, da Frontend die URL mitgeben sollte
redirectURL = "http://localhost:3000"
}
logger.Debug("Reset-Anfrage für E-Mail: %s", input.Email)
var user User
if err := user.FindByEmail(input.Email); err != nil {
// Aus Sicherheitsgründen keine Information preisgeben, ob die E-Mail existiert
logger.Warn("Reset-Anfrage für nicht existierende E-Mail: %s", input.Email)
c.JSON(http.StatusOK, gin.H{
"message": "Falls ein Account mit dieser E-Mail-Adresse existiert, wurde eine Reset-E-Mail gesendet.",
})
return
}
// Generiere Reset-Hash
resetHash, err := generateResetHash()
if err != nil {
logger.Error("Fehler beim Generieren des Reset-Hashes: %s", err.Error())
respondWithError(c, http.StatusInternalServerError, "Fehler beim Verarbeiten der Anfrage")
return
}
// Speichere Reset-Hash in der Datenbank
if err := user.SetPasswordResetHash(resetHash); err != nil {
logger.Error("Fehler beim Speichern des Reset-Hashes für Benutzer %s: %s", user.Username, err.Error())
respondWithError(c, http.StatusInternalServerError, "Fehler beim Verarbeiten der Anfrage")
return
}
// Sende Reset-E-Mail
if err := sendResetEmail(user.Email, user.Username, resetHash, redirectURL); err != nil {
logger.Error("Fehler beim Senden der Reset-E-Mail für Benutzer %s: %s", user.Username, err.Error())
respondWithError(c, http.StatusInternalServerError, "Fehler beim Senden der E-Mail")
return
}
logger.Info("Reset-E-Mail erfolgreich für Benutzer %s (%s) gesendet", user.Username, user.Email)
c.JSON(http.StatusOK, gin.H{
"message": "Falls ein Account mit dieser E-Mail-Adresse existiert, wurde eine Reset-E-Mail gesendet.",
})
}
// ResetPassword Handler für das Zurücksetzen des Passworts
func ResetPassword(c *gin.Context) {
logger.Debug("Starte Passwort-Reset...")
var input struct {
Token string `json:"token" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6"`
}
if err := c.ShouldBindJSON(&input); err != nil {
logger.Error("Fehler beim Parsen der Reset-Daten: %s", err.Error())
respondWithError(c, http.StatusBadRequest, "Token und neues Passwort (mind. 6 Zeichen) erforderlich")
return
}
logger.Debug("Reset-Versuch mit Token: %s", input.Token[:10]+"...")
var user User
if err := user.FindByResetHash(input.Token); err != nil {
logger.Warn("Ungültiger oder abgelaufener Reset-Token verwendet")
respondWithError(c, http.StatusBadRequest, "Ungültiger oder abgelaufener Reset-Link")
return
}
// Zusätzliche Validierung des Tokens
if !user.IsResetHashValid(input.Token) {
logger.Warn("Reset-Token-Validierung fehlgeschlagen für Benutzer: %s", user.Username)
respondWithError(c, http.StatusBadRequest, "Ungültiger oder abgelaufener Reset-Link")
return
}
// Neues Passwort hashen (gleiche Methode wie bei der Registrierung)
hashedPassword := md5.Sum([]byte(input.NewPassword))
user.PasswordHash = hex.EncodeToString(hashedPassword[:])
// Reset-Hash entfernen
if err := user.ClearPasswordResetHash(); err != nil {
logger.Error("Fehler beim Entfernen des Reset-Hashes für Benutzer %s: %s", user.Username, err.Error())
respondWithError(c, http.StatusInternalServerError, "Fehler beim Aktualisieren des Accounts")
return
}
// Passwort speichern
if err := user.Save(); err != nil {
logger.Error("Fehler beim Speichern des neuen Passworts für Benutzer %s: %s", user.Username, err.Error())
respondWithError(c, http.StatusInternalServerError, "Fehler beim Aktualisieren des Passworts")
return
}
logger.Info("Passwort erfolgreich zurückgesetzt für Benutzer: %s", user.Username)
c.JSON(http.StatusOK, gin.H{
"message": "Passwort erfolgreich zurückgesetzt",
})
}
// ValidateResetToken Handler zur Validierung eines Reset-Tokens
func ValidateResetToken(c *gin.Context) {
logger.Debug("Validiere Reset-Token...")
token := c.Param("token")
//token := c.Query("token")
if token == "" {
respondWithError(c, http.StatusBadRequest, "Token erforderlich")
return
}
var user User
if err := user.FindByResetHash(token); err != nil {
logger.Debug("Reset-Token nicht gefunden oder abgelaufen")
respondWithError(c, http.StatusBadRequest, "Ungültiger oder abgelaufener Reset-Link")
return
}
if !user.IsResetHashValid(token) {
logger.Debug("Reset-Token-Validierung fehlgeschlagen")
respondWithError(c, http.StatusBadRequest, "Ungültiger oder abgelaufener Reset-Link")
return
}
logger.Debug("Reset-Token gültig für Benutzer: %s", user.Username)
c.JSON(http.StatusOK, gin.H{
"valid": true,
"username": user.Username,
"expires": user.ResetPwHashExpires,
})
}
+43 -6
View File
@@ -9,12 +9,14 @@ import (
)
type User struct {
UserID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"unique" json:"username"`
PasswordHash string `json:"password"`
Email string `gorm:"unique" json:"email"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
UserID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"unique" json:"username"`
PasswordHash string `json:"password"`
Email string `gorm:"unique" json:"email"`
ResetPwHash *string `gorm:"index" json:"-"` // Hash für Password-Reset (wird nicht serialisiert)
ResetPwHashExpires *time.Time `json:"-"` // Ablaufzeit für Password-Reset-Hash
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (object *User) Create() error {
@@ -55,3 +57,38 @@ func (object *User) Save() error {
}
return nil
}
// FindByEmail findet einen User anhand der E-Mail-Adresse
func (object *User) FindByEmail(email string) error {
err := database.DB.First(&object, "email = ?", email).Error
return err
}
// FindByResetHash findet einen User anhand des Reset-Hashes
func (object *User) FindByResetHash(resetHash string) error {
err := database.DB.First(&object, "reset_pw_hash = ? AND reset_pw_hash_expires > ?", resetHash, time.Now()).Error
return err
}
// SetPasswordResetHash setzt den Reset-Hash und die Ablaufzeit (14 Tage)
func (object *User) SetPasswordResetHash(resetHash string) error {
expiryTime := time.Now().Add(14 * 24 * time.Hour) // 14 Tage gültig
object.ResetPwHash = &resetHash
object.ResetPwHashExpires = &expiryTime
return object.Save()
}
// ClearPasswordResetHash entfernt den Reset-Hash
func (object *User) ClearPasswordResetHash() error {
object.ResetPwHash = nil
object.ResetPwHashExpires = nil
return object.Save()
}
// IsResetHashValid prüft ob der Reset-Hash gültig und nicht abgelaufen ist
func (object *User) IsResetHashValid(resetHash string) bool {
if object.ResetPwHash == nil || object.ResetPwHashExpires == nil {
return false
}
return *object.ResetPwHash == resetHash && time.Now().Before(*object.ResetPwHashExpires)
}
@@ -0,0 +1,100 @@
<template>
<div class="fullwidth-page" style="display: flex; justify-content: center; align-items: center; min-height: 100vh;">
<div class="card" style="max-width: 400px; width: 100%; margin: 20px;">
<div class="page-header">
<h2>Passwort zurücksetzen</h2>
<p style="color: #666; font-size: 0.9em; margin-top: 10px;">
Geben Sie Ihre E-Mail-Adresse ein, um einen Reset-Link zu erhalten.
</p>
</div>
<form @submit.prevent="requestReset" v-if="!submitted">
<div class="form-group">
<label for="email">E-Mail-Adresse</label>
<input
v-model="email"
type="email"
id="email"
name="email"
class="form-control"
placeholder="ihre@email.de"
required
/>
</div>
<button
type="submit"
class="btn btn-primary"
style="width: 100%; margin-top: 10px;"
:disabled="isLoading"
>
<span v-if="isLoading">Wird gesendet...</span>
<span v-else>Reset-Link senden</span>
</button>
</form>
<div v-if="submitted" class="badge badge-success" style="width: 100%; margin-top: 15px; text-align: center; display: block;">
<p style="margin: 10px 0;">
<strong>E-Mail gesendet!</strong>
</p>
<p style="font-size: 0.9em; margin: 5px 0;">
Falls ein Account mit dieser E-Mail-Adresse existiert, wurde ein Reset-Link gesendet.
</p>
<p style="font-size: 0.8em; margin: 5px 0; opacity: 0.8;">
Prüfen Sie Ihre E-Mails und folgen Sie dem Link.
</p>
</div>
<div v-if="error" class="badge badge-danger" style="width: 100%; margin-top: 15px; text-align: center; display: block;">
{{ error }}
</div>
<div style="text-align: center; margin-top: 20px; padding-top: 15px; border-top: 1px solid #dee2e6;">
<router-link to="/" class="btn btn-secondary">
Zurück zum Login
</router-link>
</div>
</div>
</div>
</template>
<script>
import API from '../utils/api'
export default {
name: 'ForgotPasswordForm',
data() {
return {
email: '',
error: '',
submitted: false,
isLoading: false,
}
},
methods: {
async requestReset() {
this.error = ''
this.isLoading = true
try {
await API.post('/password-reset/request', {
email: this.email,
redirect_url: window.location.origin, // Aktuelle Frontend-URL
})
this.submitted = true
console.log('Password reset email requested for:', this.email)
} catch (err) {
console.error('Password reset request error:', err)
this.error = err.response?.data?.error || 'Fehler beim Senden der E-Mail. Versuchen Sie es später erneut.'
} finally {
this.isLoading = false
}
},
},
}
</script>
<style scoped>
/* No custom CSS needed - using main.css classes */
</style>
+6
View File
@@ -41,6 +41,12 @@
{{ error }}
</div>
<div style="text-align: center; margin-top: 15px;">
<router-link to="/forgot-password" style="color: #007bff; text-decoration: none; font-size: 0.9em;">
Passwort vergessen?
</router-link>
</div>
<div style="text-align: center; margin-top: 20px; padding-top: 15px; border-top: 1px solid #dee2e6;">
<p>Don't have an account? <router-link to="/register" class="btn btn-secondary">Register here</router-link></p>
</div>
@@ -0,0 +1,218 @@
<template>
<div class="fullwidth-page" style="display: flex; justify-content: center; align-items: center; min-height: 100vh;">
<div class="card" style="max-width: 400px; width: 100%; margin: 20px;">
<!-- Loading State -->
<div v-if="isValidating" class="page-header" style="text-align: center;">
<h2>Validiere Reset-Link...</h2>
<div style="margin-top: 20px;">
<div class="spinner" style="margin: 0 auto;"></div>
</div>
</div>
<!-- Invalid Token -->
<div v-else-if="!isValidToken" class="page-header">
<h2>Ungültiger Reset-Link</h2>
<div class="badge badge-danger" style="width: 100%; margin-top: 15px; text-align: center; display: block;">
<p style="margin: 10px 0;">
Dieser Reset-Link ist ungültig oder abgelaufen.
</p>
<p style="font-size: 0.9em; margin: 5px 0;">
Bitte fordern Sie einen neuen Reset-Link an.
</p>
</div>
<div style="text-align: center; margin-top: 20px;">
<router-link to="/forgot-password" class="btn btn-primary">
Neuen Reset-Link anfordern
</router-link>
</div>
</div>
<!-- Valid Token - Reset Form -->
<div v-else>
<div class="page-header">
<h2>Neues Passwort setzen</h2>
<p style="color: #666; font-size: 0.9em; margin-top: 10px;" v-if="userInfo.username">
Für Benutzer: <strong>{{ userInfo.username }}</strong>
</p>
</div>
<form @submit.prevent="resetPassword" v-if="!resetSuccess">
<div class="form-group">
<label for="newPassword">Neues Passwort</label>
<input
v-model="newPassword"
type="password"
id="newPassword"
name="newPassword"
class="form-control"
placeholder="Mindestens 6 Zeichen"
required
minlength="6"
/>
</div>
<div class="form-group">
<label for="confirmPassword">Passwort bestätigen</label>
<input
v-model="confirmPassword"
type="password"
id="confirmPassword"
name="confirmPassword"
class="form-control"
placeholder="Passwort wiederholen"
required
minlength="6"
/>
</div>
<button
type="submit"
class="btn btn-primary"
style="width: 100%; margin-top: 10px;"
:disabled="isResetting || !passwordsMatch"
>
<span v-if="isResetting">Passwort wird gesetzt...</span>
<span v-else>Passwort zurücksetzen</span>
</button>
<div v-if="!passwordsMatch && confirmPassword" class="badge badge-warning" style="width: 100%; margin-top: 10px; text-align: center; display: block; font-size: 0.8em;">
Die Passwörter stimmen nicht überein
</div>
</form>
<!-- Success Message -->
<div v-if="resetSuccess" class="badge badge-success" style="width: 100%; margin-top: 15px; text-align: center; display: block;">
<p style="margin: 10px 0;">
<strong>Passwort erfolgreich zurückgesetzt!</strong>
</p>
<p style="font-size: 0.9em; margin: 5px 0;">
Sie können sich jetzt mit Ihrem neuen Passwort anmelden.
</p>
<div style="margin-top: 15px;">
<router-link to="/" class="btn btn-primary">
Zum Login
</router-link>
</div>
</div>
<div v-if="error" class="badge badge-danger" style="width: 100%; margin-top: 15px; text-align: center; display: block;">
{{ error }}
</div>
</div>
<!-- Back to Login (always visible) -->
<div style="text-align: center; margin-top: 20px; padding-top: 15px; border-top: 1px solid #dee2e6;" v-if="!isValidating && !resetSuccess">
<router-link to="/" class="btn btn-secondary">
Zurück zum Login
</router-link>
</div>
</div>
</div>
</template>
<script>
import API from '../utils/api'
export default {
name: 'ResetPasswordForm',
data() {
return {
token: '',
newPassword: '',
confirmPassword: '',
error: '',
isValidating: true,
isValidToken: false,
isResetting: false,
resetSuccess: false,
userInfo: {},
}
},
computed: {
passwordsMatch() {
return this.newPassword === this.confirmPassword
}
},
async mounted() {
// Get token from URL query parameter
this.token = this.$route.query.token
if (!this.token) {
this.isValidating = false
this.isValidToken = false
this.error = 'Kein Reset-Token gefunden'
return
}
await this.validateToken()
},
methods: {
async validateToken() {
try {
const response = await API.get(`/password-reset/validate/${this.token}`)
this.isValidToken = response.data.valid
this.userInfo = {
username: response.data.username,
expires: response.data.expires
}
console.log('Token validation successful:', response.data)
} catch (err) {
console.error('Token validation error:', err)
this.isValidToken = false
this.error = 'Reset-Link ist ungültig oder abgelaufen'
} finally {
this.isValidating = false
}
},
async resetPassword() {
if (!this.passwordsMatch) {
this.error = 'Die Passwörter stimmen nicht überein'
return
}
if (this.newPassword.length < 6) {
this.error = 'Das Passwort muss mindestens 6 Zeichen lang sein'
return
}
this.error = ''
this.isResetting = true
try {
await API.post('/password-reset/reset', {
token: this.token,
new_password: this.newPassword,
})
this.resetSuccess = true
console.log('Password reset successful')
} catch (err) {
console.error('Password reset error:', err)
this.error = err.response?.data?.error || 'Fehler beim Zurücksetzen des Passworts. Versuchen Sie es erneut.'
} finally {
this.isResetting = false
}
},
},
}
</script>
<style scoped>
.spinner {
width: 20px;
height: 20px;
border: 2px solid #f3f3f3;
border-top: 2px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
+4
View File
@@ -2,6 +2,8 @@ import { createRouter, createWebHistory } from "vue-router";
import { isLoggedIn } from "../utils/auth"; // Import the helper function
import LoginView from "../views/LoginView.vue";
import RegisterView from "../views/RegisterView.vue";
import ForgotPasswordView from "../views/ForgotPasswordView.vue";
import ResetPasswordView from "../views/ResetPasswordView.vue";
import DashboardView from "../views/DashboardView.vue";
import AusruestungView from "../views/AusruestungView.vue";
import MaintenanceView from "../views/MaintenanceView.vue";
@@ -16,6 +18,8 @@ import CharacterCreation from "@/components/CharacterCreation.vue";
const routes = [
{ path: "/", name: "Login", component: LoginView },
{ path: "/register", name: "Register", component: RegisterView },
{ path: "/forgot-password", name: "ForgotPassword", component: ForgotPasswordView },
{ path: "/reset-password", name: "ResetPassword", component: ResetPasswordView },
{ path: "/dashboard", name: "Dashboard", component: DashboardView, meta: { requiresAuth: true } },
{ path: "/ausruestung/:characterId", name: "Ausruestung", component: AusruestungView, meta: { requiresAuth: true } },
{ path: "/maintenance", name: "Maintenance", component: MaintenanceView, meta: { requiresAuth: true } },
+14
View File
@@ -0,0 +1,14 @@
<template>
<ForgotPasswordForm />
</template>
<script>
import ForgotPasswordForm from '../components/ForgotPasswordForm.vue'
export default {
name: 'ForgotPasswordView',
components: {
ForgotPasswordForm
}
}
</script>
+14
View File
@@ -0,0 +1,14 @@
<template>
<ResetPasswordForm />
</template>
<script>
import ResetPasswordForm from '../components/ResetPasswordForm.vue'
export default {
name: 'ResetPasswordView',
components: {
ResetPasswordForm
}
}
</script>