395 lines
14 KiB
Go
395 lines
14 KiB
Go
package server
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
func (s *Server) requireUserWeb(w http.ResponseWriter, r *http.Request) (string, bool) {
|
|
session, ok := s.requireSession(w, r, sessionScopeUser)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
return session.SubjectID, true
|
|
}
|
|
|
|
func (s *Server) renderWebError(w http.ResponseWriter, r *http.Request, status int, message, back string) {
|
|
s.renderPageStatus(w, r, "error", webPage{Title: "error.label", Heading: "error.badRequest", Message: message, BackURL: back}, status)
|
|
}
|
|
|
|
func (s *Server) handleUserWebRegister(w http.ResponseWriter, r *http.Request) {
|
|
if !s.cfg.Web.AllowRegistration {
|
|
s.renderWebError(w, r, http.StatusNotFound, "error.registrationDisabled", "/login")
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
s.renderPage(w, r, "register", webPage{Title: "auth.registerTitle"})
|
|
case http.MethodPost:
|
|
if err := r.ParseForm(); err != nil {
|
|
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/register")
|
|
return
|
|
}
|
|
username, email, password := strings.TrimSpace(r.FormValue("username")), strings.TrimSpace(r.FormValue("email")), r.FormValue("password")
|
|
if username == "" || email == "" || password == "" {
|
|
s.renderPage(w, r, "register", webPage{Title: "auth.registerTitle", Flash: "error.allFieldsRequired"})
|
|
return
|
|
}
|
|
if !s.allowRate(w, r, "register", email) {
|
|
return
|
|
}
|
|
if err := validatePassword(password); err != "" {
|
|
s.renderPage(w, r, "register", webPage{Title: "auth.registerTitle", Flash: "error.passwordInvalid"})
|
|
return
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
log.Printf("web register: password hashing: %v", err)
|
|
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/register")
|
|
return
|
|
}
|
|
id := make([]byte, 12)
|
|
if _, err := rand.Read(id); err != nil {
|
|
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/register")
|
|
return
|
|
}
|
|
userID := hex.EncodeToString(id)
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
if _, err := s.db.Exec("INSERT INTO server_users (id, username, email, password_hash, confirmed, created_at) VALUES (?, ?, ?, ?, 0, ?)", userID, username, strings.ToLower(email), string(hash), now); err != nil {
|
|
if strings.Contains(err.Error(), "UNIQUE") {
|
|
s.renderPage(w, r, "register", webPage{Title: "auth.registerTitle", Flash: "error.accountTaken"})
|
|
return
|
|
}
|
|
log.Printf("web register: create user: %v", err)
|
|
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/register")
|
|
return
|
|
}
|
|
token, err := issueEmailToken(s.db, userID, "confirm", 48*time.Hour)
|
|
if err != nil {
|
|
log.Printf("web register: issue confirmation token: %v", err)
|
|
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/register")
|
|
return
|
|
}
|
|
if host := s.smtpGet("smtp_host"); host != "" {
|
|
base := s.smtpGet("server_url")
|
|
if base == "" {
|
|
base = "http://" + r.Host
|
|
}
|
|
confirmURL := fmt.Sprintf("%s/api/v1/auth/confirm?token=%s", strings.TrimRight(base, "/"), token)
|
|
if err := s.smtpSend(email, t(s.webLocale(r), "server.emailConfirmSubject"), fmt.Sprintf(t(s.webLocale(r), "server.emailConfirmBody"), confirmURL)); err != nil {
|
|
log.Printf("web register: confirmation mail: %v", err)
|
|
}
|
|
} else if s.cfg.DevelopmentTokenLogging {
|
|
log.Printf("development confirmation token for user %s: %s", username, token)
|
|
}
|
|
http.Redirect(w, r, "/register/result", http.StatusSeeOther)
|
|
default:
|
|
methodNotAllowed(w, http.MethodGet, http.MethodPost)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleUserWebForgot(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
s.renderPage(w, r, "forgot", webPage{Title: "auth.forgotTitle"})
|
|
case http.MethodPost:
|
|
if err := r.ParseForm(); err != nil {
|
|
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/forgot")
|
|
return
|
|
}
|
|
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
|
|
if email == "" {
|
|
s.renderPage(w, r, "forgot", webPage{Title: "auth.forgotTitle", Flash: "error.emailRequired"})
|
|
return
|
|
}
|
|
if !s.allowRate(w, r, "forgot", email) {
|
|
return
|
|
}
|
|
var userID string
|
|
if err := s.db.QueryRow("SELECT id FROM server_users WHERE email=?", email).Scan(&userID); err == nil {
|
|
token, issueErr := issueEmailToken(s.db, userID, "reset", time.Hour)
|
|
if issueErr != nil {
|
|
log.Printf("web forgot: issue reset token: %v", issueErr)
|
|
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/forgot")
|
|
return
|
|
}
|
|
if s.smtpGet("smtp_host") != "" {
|
|
base := s.smtpGet("server_url")
|
|
if base == "" {
|
|
base = "http://" + r.Host
|
|
}
|
|
resetURL := fmt.Sprintf("%s/reset?token=%s", strings.TrimRight(base, "/"), token)
|
|
if err := s.smtpSend(email, t(s.webLocale(r), "server.emailResetSubject"), fmt.Sprintf(t(s.webLocale(r), "server.emailResetBody"), resetURL)); err != nil {
|
|
log.Printf("web forgot: reset mail: %v", err)
|
|
}
|
|
} else if s.cfg.DevelopmentTokenLogging {
|
|
log.Printf("development reset token requested for %s: %s", email, token)
|
|
}
|
|
}
|
|
http.Redirect(w, r, "/forgot/sent", http.StatusSeeOther)
|
|
default:
|
|
methodNotAllowed(w, http.MethodGet, http.MethodPost)
|
|
}
|
|
}
|
|
|
|
func (s *Server) validResetToken(token string) bool {
|
|
var expiresAt string
|
|
if err := s.db.QueryRow("SELECT expires_at FROM server_email_tokens WHERE token_hash=? AND purpose='reset'", emailTokenHash(token)).Scan(&expiresAt); err != nil {
|
|
return false
|
|
}
|
|
expires, err := time.Parse(time.RFC3339, expiresAt)
|
|
return err == nil && time.Now().Before(expires)
|
|
}
|
|
|
|
func (s *Server) handleUserWebReset(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
token := r.URL.Query().Get("token")
|
|
if token == "" || !s.validResetToken(token) {
|
|
http.Redirect(w, r, "/forgot", http.StatusFound)
|
|
return
|
|
}
|
|
s.renderPage(w, r, "reset", webPage{Title: "auth.resetTitle", Token: token})
|
|
case http.MethodPost:
|
|
if err := r.ParseForm(); err != nil {
|
|
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/forgot")
|
|
return
|
|
}
|
|
token, password, confirm := r.FormValue("token"), r.FormValue("password"), r.FormValue("confirm")
|
|
if token == "" || password == "" || confirm == "" {
|
|
s.renderPage(w, r, "reset", webPage{Title: "auth.resetTitle", Token: token, Flash: "error.allFieldsRequired"})
|
|
return
|
|
}
|
|
if !s.allowRate(w, r, "reset", "") {
|
|
return
|
|
}
|
|
if err := validatePassword(password); err != "" {
|
|
s.renderPage(w, r, "reset", webPage{Title: "auth.resetTitle", Token: token, Flash: "error.passwordInvalid"})
|
|
return
|
|
}
|
|
if password != confirm {
|
|
s.renderPage(w, r, "reset", webPage{Title: "auth.resetTitle", Token: token, Flash: "error.passwordMismatch"})
|
|
return
|
|
}
|
|
userID, err := s.resetPasswordWithToken(token, password)
|
|
if err == errResetTokenInvalid || err == errResetTokenExpired {
|
|
http.Redirect(w, r, "/forgot", http.StatusFound)
|
|
return
|
|
}
|
|
if err != nil {
|
|
log.Printf("web reset: %v", err)
|
|
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/forgot")
|
|
return
|
|
}
|
|
log.Printf("reset: user %s reset password", userID)
|
|
http.Redirect(w, r, "/reset/done", http.StatusSeeOther)
|
|
default:
|
|
methodNotAllowed(w, http.MethodGet, http.MethodPost)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleUserWebLogin(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
s.renderPage(w, r, "login", webPage{Title: "auth.loginTitle"})
|
|
case http.MethodPost:
|
|
if err := r.ParseForm(); err != nil {
|
|
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/login")
|
|
return
|
|
}
|
|
login, password := strings.TrimSpace(r.FormValue("username")), r.FormValue("password")
|
|
if !s.allowRate(w, r, "login", login) {
|
|
return
|
|
}
|
|
var userID, hash string
|
|
var confirmed, blocked int
|
|
err := s.db.QueryRow("SELECT id, password_hash, confirmed, blocked FROM server_users WHERE username=? OR email=?", login, strings.ToLower(login)).Scan(&userID, &hash, &confirmed, &blocked)
|
|
if err != nil || blocked != 0 || confirmed == 0 || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
|
|
s.renderPageStatus(w, r, "login", webPage{Title: "auth.loginTitle", Flash: "error.invalidCredentials"}, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
token, csrf, err := s.createSession(sessionScopeUser, userID)
|
|
if err != nil {
|
|
log.Printf("web login: session: %v", err)
|
|
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/login")
|
|
return
|
|
}
|
|
s.setSessionCookies(w, r, sessionScopeUser, token, csrf)
|
|
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
|
default:
|
|
methodNotAllowed(w, http.MethodGet, http.MethodPost)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleUserDashboard(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w, http.MethodGet)
|
|
return
|
|
}
|
|
userID, ok := s.requireUserWeb(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var username, email string
|
|
if err := s.db.QueryRow("SELECT username, email FROM server_users WHERE id=?", userID).Scan(&username, &email); err != nil {
|
|
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/")
|
|
return
|
|
}
|
|
rows, err := s.db.Query(`SELECT d.id, d.name, COALESCE(d.vault_id,''), COALESCE(d.client_version,''), COALESCE(d.last_seen,''), COALESCE(d.revoked_at,''), d.created_at FROM server_devices d JOIN server_user_devices ud ON ud.device_id=d.id WHERE ud.user_id=? ORDER BY d.created_at DESC`, userID)
|
|
if err != nil {
|
|
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
var devices []webDevice
|
|
for rows.Next() {
|
|
var d webDevice
|
|
var revoked string
|
|
if err := rows.Scan(&d.ID, &d.Name, &d.Vault, &d.ClientVersion, &d.LastSeen, &revoked, &d.CreatedAt); err != nil {
|
|
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/")
|
|
return
|
|
}
|
|
d.Revoked = revoked != ""
|
|
if d.LastSeen == "" {
|
|
d.LastSeen = "—"
|
|
}
|
|
devices = append(devices, d)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/")
|
|
return
|
|
}
|
|
flash := r.URL.Query().Get("error")
|
|
if flash != "error.invalidCredentials" {
|
|
flash = ""
|
|
}
|
|
s.renderPage(w, r, "dashboard", webPage{Title: "user.account", UserName: username, Email: email, Devices: devices, Flash: flash})
|
|
}
|
|
|
|
func (s *Server) handleUserWebLogout(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
if !s.requireUserMutation(w, r) {
|
|
return
|
|
}
|
|
if cookie, err := r.Cookie("user_session"); err == nil {
|
|
if err := s.deleteSession(cookie.Value); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
}
|
|
s.clearSessionCookies(w, r, sessionScopeUser)
|
|
http.Redirect(w, r, "/login", http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) handleUserDevices(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w, http.MethodGet)
|
|
return
|
|
}
|
|
userID, ok := s.requireUserWeb(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
rows, err := s.db.Query(`SELECT d.id,d.name,COALESCE(d.client_version,''),COALESCE(d.last_seen,''),COALESCE(d.revoked_at,''),d.created_at FROM server_devices d JOIN server_user_devices ud ON ud.device_id=d.id WHERE ud.user_id=? ORDER BY d.created_at DESC`, userID)
|
|
if err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
var devices []map[string]string
|
|
for rows.Next() {
|
|
var id, name, version, lastSeen, revoked, created string
|
|
if err := rows.Scan(&id, &name, &version, &lastSeen, &revoked, &created); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
devices = append(devices, map[string]string{"id": id, "name": name, "client_version": version, "last_seen": lastSeen, "revoked_at": revoked, "created_at": created})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
jsonOK(w, devices)
|
|
}
|
|
|
|
// handleUserWebDeviceAction accepts the dashboard's regular form as well as
|
|
// the existing JSON API. Both paths are session and CSRF protected.
|
|
func (s *Server) handleUserWebDeviceAction(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
session, ok := s.requireSession(w, r, sessionScopeUser)
|
|
if !ok || !s.verifyCSRF(w, r, session) {
|
|
return
|
|
}
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/v1/user/devices/")
|
|
if !strings.HasSuffix(path, "/revoke") {
|
|
jsonErr(w, http.StatusNotFound, "not found")
|
|
return
|
|
}
|
|
deviceID := strings.TrimSuffix(strings.TrimSuffix(path, "/revoke"), "/")
|
|
password := ""
|
|
formRequest := strings.HasPrefix(r.Header.Get("Content-Type"), "application/x-www-form-urlencoded")
|
|
if formRequest {
|
|
password = r.FormValue("password")
|
|
} else {
|
|
var req struct {
|
|
Password string `json:"password"`
|
|
}
|
|
if !decodeJSONBody(w, r, &req, s.cfg.Limits.MaxJSONBody) {
|
|
return
|
|
}
|
|
password = req.Password
|
|
}
|
|
if password == "" || !s.allowRate(w, r, "auth-test", session.SubjectID) {
|
|
if password == "" {
|
|
jsonErrCode(w, http.StatusBadRequest, "invalid_request", "password required")
|
|
}
|
|
return
|
|
}
|
|
var hash string
|
|
if err := s.db.QueryRow("SELECT password_hash FROM server_users WHERE id=?", session.SubjectID).Scan(&hash); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
|
|
if formRequest {
|
|
http.Redirect(w, r, "/dashboard?error=error.invalidCredentials", http.StatusSeeOther)
|
|
} else {
|
|
jsonErr(w, http.StatusForbidden, "wrong password")
|
|
}
|
|
return
|
|
}
|
|
var owner string
|
|
if err := s.db.QueryRow("SELECT user_id FROM server_devices WHERE id=?", deviceID).Scan(&owner); err != nil {
|
|
jsonErr(w, http.StatusNotFound, "device not found")
|
|
return
|
|
}
|
|
if owner != session.SubjectID {
|
|
jsonErr(w, http.StatusForbidden, "device does not belong to you")
|
|
return
|
|
}
|
|
if err := s.revokeDevice(deviceID, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
s.auditLog("device_revoked", session.SubjectID, deviceID, s.clientIP(r), "device revoked from web dashboard")
|
|
if formRequest {
|
|
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
|
return
|
|
}
|
|
jsonOK(w, map[string]string{"status": "revoked"})
|
|
}
|