feat: forgot/reset password pages, login link, consistent error page helper, fix reset URL bug
This commit is contained in:
parent
b0d992b0d6
commit
7fe02fc8df
|
|
@ -330,6 +330,8 @@ func (s *Server) routes() *http.ServeMux {
|
|||
mux.HandleFunc("/api/v1/auth/login", s.handleUserLogin)
|
||||
mux.HandleFunc("/api/v1/auth/forgot", s.handleForgot)
|
||||
mux.HandleFunc("/api/v1/auth/reset", s.handleReset)
|
||||
mux.HandleFunc("/forgot", s.handleUserWebForgot)
|
||||
mux.HandleFunc("/reset", s.handleUserWebReset)
|
||||
mux.HandleFunc("/api/v1/user/devices", s.handleUserDevices)
|
||||
mux.HandleFunc("/register", s.handleUserWebRegister)
|
||||
mux.HandleFunc("/login", s.handleUserWebLogin)
|
||||
|
|
@ -403,6 +405,26 @@ func (s *Server) smtpSet(key, val string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
func errorPageHTML(title, msg, backURL string) string {
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — %s</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;text-align:center;max-width:360px}
|
||||
h1{font-size:18px;margin:0 0 12px;color:#ff6b6b}
|
||||
p{font-size:13px;color:#b0b0c0;margin:0 0 16px}
|
||||
a{color:#6366f1;text-decoration:none}
|
||||
a:hover{text-decoration:underline}</style>
|
||||
</head><body>
|
||||
<div class="box">
|
||||
<h1>%s</h1>
|
||||
<p>%s</p>
|
||||
<a href="%s">← Назад</a>
|
||||
</div>
|
||||
</body></html>`, title, title, msg, backURL)
|
||||
}
|
||||
|
||||
func sel(v, want string) string {
|
||||
if v == want {
|
||||
return " selected"
|
||||
|
|
@ -832,9 +854,10 @@ func (s *Server) handleForgot(w http.ResponseWriter, r *http.Request) {
|
|||
tokenStr, userID, exp, now)
|
||||
host := s.smtpGet("smtp_host")
|
||||
if host != "" {
|
||||
resetURL := fmt.Sprintf("%s/reset?token=%s", s.smtpGet("server_url"), tokenStr)
|
||||
if resetURL == "" {
|
||||
resetURL = fmt.Sprintf("/api/v1/auth/reset?token=%s", tokenStr)
|
||||
srvURL := s.smtpGet("server_url")
|
||||
resetURL := fmt.Sprintf("/api/v1/auth/reset?token=%s", tokenStr)
|
||||
if srvURL != "" {
|
||||
resetURL = fmt.Sprintf("%s/api/v1/auth/reset?token=%s", srvURL, tokenStr)
|
||||
}
|
||||
body := fmt.Sprintf("Reset your Verstak Sync password:\n\n%s\n\nThis link expires in 1 hour.", resetURL)
|
||||
s.smtpSend(req.Email, "Verstak Sync password reset", body)
|
||||
|
|
@ -1221,6 +1244,121 @@ func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleUserWebForgot(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(forgotPasswordHTML))
|
||||
case "POST":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
jsonErr(w, 400, "bad form")
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(r.FormValue("email"))
|
||||
if email == "" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(errorPageHTML("Ошибка", "Email обязателен", "/forgot")))
|
||||
return
|
||||
}
|
||||
var userID string
|
||||
err := s.db.QueryRow("SELECT id FROM server_users WHERE email=?", email).Scan(&userID)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(forgotSentHTML))
|
||||
return
|
||||
}
|
||||
tok := make([]byte, 24)
|
||||
rand.Read(tok)
|
||||
tokenStr := hex.EncodeToString(tok)
|
||||
exp := time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
s.db.Exec("INSERT INTO server_email_tokens (token, user_id, purpose, expires_at, created_at) VALUES (?, ?, 'reset', ?, ?)",
|
||||
tokenStr, userID, exp, now)
|
||||
host := s.smtpGet("smtp_host")
|
||||
if host != "" {
|
||||
srvURL := s.smtpGet("server_url")
|
||||
resetURL := fmt.Sprintf("/reset?token=%s", tokenStr)
|
||||
if srvURL != "" {
|
||||
resetURL = fmt.Sprintf("%s/reset?token=%s", srvURL, tokenStr)
|
||||
}
|
||||
body := fmt.Sprintf("Reset your Verstak Sync password:\n\n%s\n\nThis link expires in 1 hour.", resetURL)
|
||||
if err := s.smtpSend(email, "Verstak Sync password reset", body); err != nil {
|
||||
log.Printf("forgot web: failed to send reset email: %v", err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("forgot web: SMTP not configured, reset token=%s for email %s", tokenStr, email)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(forgotSentHTML))
|
||||
default:
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
http.Redirect(w, r, "/forgot", http.StatusFound)
|
||||
return
|
||||
}
|
||||
// Validate token exists and not expired before showing form.
|
||||
var userID, expiresAt string
|
||||
err := s.db.QueryRow("SELECT user_id, expires_at FROM server_email_tokens WHERE token=? AND purpose='reset'",
|
||||
token).Scan(&userID, &expiresAt)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/forgot", http.StatusFound)
|
||||
return
|
||||
}
|
||||
exp, err := time.Parse(time.RFC3339, expiresAt)
|
||||
if err != nil || time.Now().After(exp) {
|
||||
http.Redirect(w, r, "/forgot", http.StatusFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
html := fmt.Sprintf(resetPasswordHTML, token)
|
||||
w.Write([]byte(html))
|
||||
case "POST":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
jsonErr(w, 400, "bad form")
|
||||
return
|
||||
}
|
||||
token := r.FormValue("token")
|
||||
newPass := r.FormValue("password")
|
||||
confirm := r.FormValue("confirm")
|
||||
if token == "" || newPass == "" || confirm == "" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(errorPageHTML("Ошибка", "Все поля обязательны", "/forgot")))
|
||||
return
|
||||
}
|
||||
if err := validatePassword(newPass); err != "" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(errorPageHTML("Ошибка", err, "/reset?token="+token)))
|
||||
return
|
||||
}
|
||||
if newPass != confirm {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(errorPageHTML("Ошибка", "Пароли не совпадают", "/reset?token="+token)))
|
||||
return
|
||||
}
|
||||
var userID string
|
||||
err := s.db.QueryRow("SELECT user_id FROM server_email_tokens WHERE token=? AND purpose='reset'", token).Scan(&userID)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/forgot", http.StatusFound)
|
||||
return
|
||||
}
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(newPass), bcrypt.DefaultCost)
|
||||
s.db.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), userID)
|
||||
s.db.Exec("DELETE FROM server_email_tokens WHERE token=?", token)
|
||||
log.Printf("reset: user %s reset password", userID)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(resetDoneHTML))
|
||||
default:
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleUserWebLogin(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
|
|
@ -1886,7 +2024,10 @@ a{color:#6366f1}
|
|||
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
|
||||
input{width:100%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
|
||||
button{width:100%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
|
||||
button:hover{background:#4f46e5}</style>
|
||||
button:hover{background:#4f46e5}
|
||||
.links{margin-top:16px;text-align:center;font-size:12px;color:#666;line-height:1.8}
|
||||
.links a{color:#6366f1;text-decoration:none}
|
||||
.links a:hover{text-decoration:underline}</style>
|
||||
</head><body>
|
||||
<form method="POST">
|
||||
<h1>Verstak Sync</h1>
|
||||
|
|
@ -1895,7 +2036,10 @@ button:hover{background:#4f46e5}</style>
|
|||
<label>Пароль</label>
|
||||
<input type="password" name="password" required>
|
||||
<button>Войти</button>
|
||||
<p><a href="/register">Зарегистрироваться</a> · <a href="/admin/login">Администратор?</a></p>
|
||||
<div class="links">
|
||||
<a href="/forgot">Забыли пароль?</a><br>
|
||||
<a href="/register">Зарегистрироваться</a> · <a href="/admin/login">Администратор?</a>
|
||||
</div>
|
||||
</form>
|
||||
</body></html>`
|
||||
|
||||
|
|
@ -2160,3 +2304,91 @@ a{color:#6366f1;text-decoration:none}
|
|||
<a href="/login" class="btn">Войти</a>
|
||||
</div>
|
||||
</body></html>`
|
||||
|
||||
const forgotPasswordHTML = `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — Восстановление пароля</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
|
||||
h1{font-size:18px;margin:0 0 8px;text-align:center}
|
||||
p{font-size:12px;color:#888;text-align:center;margin:0 0 20px}
|
||||
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
|
||||
input{width:100%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
|
||||
button{width:100%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
|
||||
button:hover{background:#4f46e5}
|
||||
.links{text-align:center;font-size:12px;color:#666;margin-top:16px}
|
||||
.links a{color:#6366f1;text-decoration:none}
|
||||
.links a:hover{text-decoration:underline}</style>
|
||||
</head><body>
|
||||
<form method="POST">
|
||||
<h1>Восстановление пароля</h1>
|
||||
<p>Введите email, указанный при регистрации</p>
|
||||
<label>Email</label>
|
||||
<input type="email" name="email" autofocus required>
|
||||
<button>Отправить ссылку</button>
|
||||
<div class="links"><a href="/login">← Вспомнили пароль?</a></div>
|
||||
</form>
|
||||
</body></html>`
|
||||
|
||||
const forgotSentHTML = `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — Письмо отправлено</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:360px;text-align:center}
|
||||
h1{font-size:18px;margin:0 0 12px;color:#34d399}
|
||||
p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5}
|
||||
a{color:#6366f1;text-decoration:none}
|
||||
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none;margin-top:16px}
|
||||
.btn:hover{background:#4f46e5}</style>
|
||||
</head><body>
|
||||
<div class="box">
|
||||
<h1>✓ Письмо отправлено</h1>
|
||||
<p>Если указанный email зарегистрирован, на него придёт ссылка для сброса пароля.</p>
|
||||
<a href="/login" class="btn">На главную</a>
|
||||
</div>
|
||||
</body></html>`
|
||||
|
||||
const resetPasswordHTML = `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — Новый пароль</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
form{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:320px}
|
||||
h1{font-size:18px;margin:0 0 20px;text-align:center}
|
||||
label{display:block;font-size:12px;color:#888;margin-bottom:4px}
|
||||
input{width:100%;padding:8px 12px;border:1px solid #2a2a3c;background:#13131f;color:#e4e4ef;border-radius:6px;font-size:14px;margin-bottom:16px;box-sizing:border-box}
|
||||
button{width:100%;padding:10px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer}
|
||||
button:hover{background:#4f46e5}
|
||||
.hint{font-size:11px;color:#666;text-align:center;margin-top:12px}</style>
|
||||
</head><body>
|
||||
<form method="POST">
|
||||
<h1>Новый пароль</h1>
|
||||
<input type="hidden" name="token" value="%s">
|
||||
<label>Новый пароль</label>
|
||||
<input type="password" name="password" minlength="8" required autofocus>
|
||||
<label>Подтвердите пароль</label>
|
||||
<input type="password" name="confirm" minlength="8" required>
|
||||
<div class="hint">Минимум 8 символов, латинские буквы и цифры</div>
|
||||
<button style="margin-top:8px">Сохранить</button>
|
||||
</form>
|
||||
</body></html>`
|
||||
|
||||
const resetDoneHTML = `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Verstak Sync — Пароль изменён</title>
|
||||
<style>body{font-family:sans-serif;background:#13131f;color:#e4e4ef;display:flex;justify-content:center;align-items:center;height:100vh;margin:0}
|
||||
.box{background:#1a1a28;border:1px solid #2a2a3c;border-radius:12px;padding:32px;width:360px;text-align:center}
|
||||
h1{font-size:18px;margin:0 0 12px;color:#34d399}
|
||||
p{font-size:13px;color:#b0b0c0;margin:0 0 6px;line-height:1.5}
|
||||
.btn{display:inline-block;padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:6px;font-size:14px;cursor:pointer;text-decoration:none;margin-top:16px}
|
||||
.btn:hover{background:#4f46e5}</style>
|
||||
</head><body>
|
||||
<div class="box">
|
||||
<h1>✓ Пароль изменён</h1>
|
||||
<p>Теперь вы можете войти с новым паролем.</p>
|
||||
<a href="/login" class="btn">Войти</a>
|
||||
</div>
|
||||
</body></html>`
|
||||
|
|
|
|||
Loading…
Reference in New Issue