395 lines
13 KiB
Go
395 lines
13 KiB
Go
package server
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
func (s *Server) handleAdminRoot(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w, http.MethodGet)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/admin/dashboard", http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) handleAdminCreateUserWeb(w http.ResponseWriter, r *http.Request) {
|
|
if !s.requireAdminCookie(w, r) {
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
s.renderPage(w, r, "admin_create_user", webPage{Title: "admin.createUser", Admin: true})
|
|
case http.MethodPost:
|
|
if !s.requireAdminMutation(w, r) {
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/admin/create-user")
|
|
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, "admin_create_user", webPage{Title: "admin.createUser", Admin: true, Flash: "error.allFieldsRequired"})
|
|
return
|
|
}
|
|
if err := validatePassword(password); err != "" {
|
|
s.renderPage(w, r, "admin_create_user", webPage{Title: "admin.createUser", Admin: true, Flash: "error.passwordInvalid"})
|
|
return
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
id := make([]byte, 12)
|
|
if _, err := rand.Read(id); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
userID := hex.EncodeToString(id)
|
|
if _, err := s.db.Exec("INSERT INTO server_users (id,username,email,password_hash,confirmed,created_at) VALUES (?, ?, ?, ?, 1, ?)", userID, username, strings.ToLower(email), string(hash), time.Now().UTC().Format(time.RFC3339)); err != nil {
|
|
if strings.Contains(err.Error(), "UNIQUE") {
|
|
s.renderPage(w, r, "admin_create_user", webPage{Title: "admin.createUser", Admin: true, Flash: "error.accountTaken"})
|
|
return
|
|
}
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
s.auditLog("user_created", userID, "", s.clientIP(r), "created by administrator")
|
|
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
|
default:
|
|
methodNotAllowed(w, http.MethodGet, http.MethodPost)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleAdminWeb(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
methodNotAllowed(w, http.MethodGet)
|
|
return
|
|
}
|
|
if !s.requireAdminCookie(w, r) {
|
|
return
|
|
}
|
|
page := strings.TrimPrefix(r.URL.Path, "/admin/")
|
|
if page == "" || page == "admin" {
|
|
page = "dashboard"
|
|
}
|
|
allowed := map[string]bool{"dashboard": true, "users": true, "devices": true, "vaults": true, "storage": true, "audit": true, "settings": true, "diagnostics": true}
|
|
if !allowed[page] {
|
|
s.handleNotFound(w, r)
|
|
return
|
|
}
|
|
stats, err := s.Stats(r.Context())
|
|
if err != nil {
|
|
log.Printf("admin stats: %v", err)
|
|
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/")
|
|
return
|
|
}
|
|
data := webPage{Title: "admin." + page, Admin: true, AdminPage: page, Stats: stats, Health: s.healthStatus(r.Context())}
|
|
switch page {
|
|
case "users":
|
|
data.AdminUsers, err = s.webAdminUsers()
|
|
case "devices":
|
|
data.AdminDevices, err = s.webAdminDevices()
|
|
case "vaults":
|
|
data.Vaults, err = s.webVaults()
|
|
case "audit":
|
|
data.Audit, err = s.webAudit()
|
|
case "settings":
|
|
data.SMTP = s.webSMTP()
|
|
}
|
|
if err != nil {
|
|
log.Printf("admin %s: %v", page, err)
|
|
s.renderWebError(w, r, http.StatusInternalServerError, "error.internal", "/admin/dashboard")
|
|
return
|
|
}
|
|
s.renderPage(w, r, "admin", data)
|
|
}
|
|
|
|
func (s *Server) webAdminUsers() ([]webAdminUser, error) {
|
|
rows, err := s.db.Query(`SELECT u.id,u.username,u.email,u.confirmed,u.blocked,u.created_at,COALESCE(u.last_seen,''),COUNT(ud.device_id) FROM server_users u LEFT JOIN server_user_devices ud ON ud.user_id=u.id GROUP BY u.id ORDER BY u.created_at DESC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []webAdminUser
|
|
for rows.Next() {
|
|
var u webAdminUser
|
|
var confirmed, blocked int
|
|
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &confirmed, &blocked, &u.CreatedAt, &u.LastSeen, &u.Devices); err != nil {
|
|
return nil, err
|
|
}
|
|
u.Confirmed = confirmed != 0
|
|
u.Blocked = blocked != 0
|
|
out = append(out, u)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Server) webAdminDevices() ([]webAdminDevice, error) {
|
|
rows, err := s.db.Query(`SELECT d.id,d.name,COALESCE(u.username,''),COALESCE(d.vault_id,''),COALESCE(d.client_version,''),COALESCE(d.last_seen,''),COALESCE(d.revoked_at,''),d.created_at FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id ORDER BY d.created_at DESC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []webAdminDevice
|
|
for rows.Next() {
|
|
var d webAdminDevice
|
|
var revoked string
|
|
if err := rows.Scan(&d.ID, &d.Name, &d.User, &d.Vault, &d.Version, &d.LastSeen, &revoked, &d.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
d.Revoked = revoked != ""
|
|
if d.LastSeen == "" {
|
|
d.LastSeen = "—"
|
|
}
|
|
out = append(out, d)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Server) webVaults() ([]webVault, error) {
|
|
rows, err := s.db.Query(`SELECT COALESCE(u.username,''),d.vault_id,COUNT(DISTINCT d.id),COUNT(o.op_id),COALESCE(MAX(d.last_seen),'') FROM server_devices d LEFT JOIN server_users u ON u.id=d.user_id LEFT JOIN server_ops o ON o.user_id=d.user_id AND o.vault_id=d.vault_id WHERE COALESCE(d.user_id,'')!='' AND COALESCE(d.vault_id,'')!='' GROUP BY d.user_id,d.vault_id ORDER BY MAX(d.last_seen) DESC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []webVault
|
|
for rows.Next() {
|
|
var v webVault
|
|
if err := rows.Scan(&v.User, &v.Vault, &v.Devices, &v.Operations, &v.LastActivity); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Server) webAudit() ([]webAudit, error) {
|
|
rows, err := s.db.Query(`SELECT event_type,COALESCE(user_id,''),COALESCE(device_id,''),created_at FROM server_audit_log ORDER BY id DESC LIMIT 100`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []webAudit
|
|
for rows.Next() {
|
|
var a webAudit
|
|
if err := rows.Scan(&a.Event, &a.User, &a.Device, &a.At); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, a)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Server) webSMTP() webSMTP {
|
|
return webSMTP{Host: s.smtpGet("smtp_host"), Port: s.smtpGet("smtp_port"), User: s.smtpGet("smtp_user"), Security: s.smtpGet("smtp_security"), From: s.smtpGet("smtp_from"), ServerURL: s.smtpGet("server_url")}
|
|
}
|
|
|
|
func (s *Server) adminReauth(r *http.Request, subject string) bool {
|
|
return s.cfg.CheckAdmin(subject, r.FormValue("password"))
|
|
}
|
|
|
|
func (s *Server) handleAdminWebAction(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
session, ok := s.requireSession(w, r, sessionScopeAdmin)
|
|
if !ok || !s.verifyCSRF(w, r, session) {
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
s.renderWebError(w, r, http.StatusBadRequest, "error.tryAgain", "/admin/dashboard")
|
|
return
|
|
}
|
|
action := r.FormValue("action")
|
|
switch action {
|
|
case "toggle-user":
|
|
if !s.adminReauth(r, session.SubjectID) {
|
|
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users")
|
|
return
|
|
}
|
|
id := r.FormValue("id")
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
var blocked int
|
|
if err := tx.QueryRow("SELECT blocked FROM server_users WHERE id=?", id).Scan(&blocked); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
s.renderWebError(w, r, http.StatusNotFound, "error.badRequest", "/admin/users")
|
|
return
|
|
}
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
newValue := 1
|
|
if blocked != 0 {
|
|
newValue = 0
|
|
}
|
|
if _, err := tx.Exec("UPDATE server_users SET blocked=? WHERE id=?", newValue, id); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
if newValue != 0 {
|
|
if _, err := tx.Exec("DELETE FROM server_sessions WHERE scope='user' AND subject_id=?", id); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
s.auditLog("user_block_changed", id, "", s.clientIP(r), "changed by administrator")
|
|
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
|
case "edit-user":
|
|
id, username, email := r.FormValue("id"), strings.TrimSpace(r.FormValue("username")), strings.ToLower(strings.TrimSpace(r.FormValue("email")))
|
|
if id == "" || username == "" || email == "" {
|
|
s.renderWebError(w, r, http.StatusBadRequest, "error.allFieldsRequired", "/admin/users")
|
|
return
|
|
}
|
|
if _, err := s.db.Exec("UPDATE server_users SET username=?, email=? WHERE id=?", username, email, id); err != nil {
|
|
if strings.Contains(err.Error(), "UNIQUE") {
|
|
s.renderWebError(w, r, http.StatusConflict, "error.accountTaken", "/admin/users")
|
|
return
|
|
}
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
s.auditLog("user_updated", id, "", s.clientIP(r), "updated by administrator")
|
|
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
|
case "reset-user-password":
|
|
if !s.adminReauth(r, session.SubjectID) {
|
|
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users")
|
|
return
|
|
}
|
|
id, password := r.FormValue("id"), r.FormValue("new_password")
|
|
if err := validatePassword(password); err != "" {
|
|
s.renderWebError(w, r, http.StatusBadRequest, "error.passwordInvalid", "/admin/users")
|
|
return
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err := tx.Exec("UPDATE server_users SET password_hash=? WHERE id=?", string(hash), id); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
if _, err := tx.Exec("DELETE FROM server_sessions WHERE scope='user' AND subject_id=?", id); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
s.auditLog("user_password_reset", id, "", s.clientIP(r), "reset by administrator")
|
|
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
|
case "delete-user":
|
|
if !s.adminReauth(r, session.SubjectID) {
|
|
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/users")
|
|
return
|
|
}
|
|
id := r.FormValue("id")
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
for _, statement := range []string{"DELETE FROM server_sessions WHERE subject_id=? AND scope='user'", "DELETE FROM server_email_tokens WHERE user_id=?", "DELETE FROM server_blob_refs WHERE user_id=?", "DELETE FROM server_idempotency_keys WHERE user_id=?", "DELETE FROM server_tombstones WHERE user_id=?", "DELETE FROM server_revisions WHERE op_id IN (SELECT op_id FROM server_ops WHERE user_id=?)", "DELETE FROM server_ops WHERE user_id=?", "DELETE FROM server_user_devices WHERE user_id=?", "DELETE FROM server_devices WHERE user_id=?", "DELETE FROM server_audit_log WHERE user_id=?", "DELETE FROM server_users WHERE id=?"} {
|
|
if _, err := tx.Exec(statement, id); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
s.auditLog("user_deleted", id, "", s.clientIP(r), "deleted by administrator")
|
|
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
|
case "revoke-device":
|
|
if !s.adminReauth(r, session.SubjectID) {
|
|
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/devices")
|
|
return
|
|
}
|
|
id := r.FormValue("id")
|
|
if err := s.revokeDevice(id, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
s.auditLog("device_revoked", "", id, s.clientIP(r), "revoked by administrator")
|
|
http.Redirect(w, r, "/admin/devices", http.StatusSeeOther)
|
|
case "smtp":
|
|
if !s.adminReauth(r, session.SubjectID) {
|
|
s.renderWebError(w, r, http.StatusForbidden, "error.invalidCredentials", "/admin/settings")
|
|
return
|
|
}
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
for _, key := range []string{"smtp_host", "smtp_port", "smtp_user", "smtp_security", "smtp_from", "server_url"} {
|
|
if _, err := tx.Exec("INSERT OR REPLACE INTO server_smtp_config (key, value) VALUES (?, ?)", key, r.FormValue(key)); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
}
|
|
if pass := r.FormValue("smtp_pass"); pass != "" {
|
|
if _, err := tx.Exec("INSERT OR REPLACE INTO server_smtp_config (key, value) VALUES (?, ?)", "smtp_pass", pass); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
s.auditLog("smtp_settings_updated", "", "", s.clientIP(r), "updated by administrator")
|
|
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
|
|
default:
|
|
s.renderWebError(w, r, http.StatusBadRequest, "error.badRequest", "/admin/dashboard")
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleAdminWebLogout(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
methodNotAllowed(w, http.MethodPost)
|
|
return
|
|
}
|
|
if !s.requireAdminMutation(w, r) {
|
|
return
|
|
}
|
|
if cookie, err := r.Cookie("admin_session"); err == nil {
|
|
if err := s.deleteSession(cookie.Value); err != nil {
|
|
jsonInternalError(w, err)
|
|
return
|
|
}
|
|
}
|
|
s.clearSessionCookies(w, r, sessionScopeAdmin)
|
|
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
|
}
|