feat(admin): log in as a user (impersonation)
- Admin /login-as: create an impersonated user session, set the user-panel cookie, redirect into that user's panel regardless of password - Session DB migration: sessions.impersonated flag to distinguish operator logins; user panel shows a 'logged in as' banner + return link - Audit: impersonated-session actions are attributed '000+<user>' via an auditActor helper (all 12 panel call sites) - Audit coverage: add user/admin login, init-script save, ipv6 pool add/del - Admin UI: 'login panel' button per user row and 'log in as' in the post-create modal (flash now carries opaque data)
This commit is contained in:
1 parent
5dd172ddcb
commit
a0ae53204d
12 files changed
+478
-39
No files matched your search
@@ -77,6 +77,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("/reset-panel-pass", s.requireAuth(s.requirePost(s.handleResetPanelPass)))
|
||||
mux.HandleFunc("/admin-pass", s.requireAuth(s.requirePost(s.handleAdminPass)))
|
||||
mux.HandleFunc("/keys", s.requireAuth(s.requirePost(s.handleAdminKeys)))
|
||||
mux.HandleFunc("/login-as", s.requireAuth(s.requirePost(s.handleLoginAs)))
|
||||
mux.HandleFunc("/flash", s.requireAuth(s.requirePost(s.handleFlash)))
|
||||
prefix := s.prefix()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -258,6 +259,15 @@ func (s *Server) redirectModal(w http.ResponseWriter, r *http.Request, path, msg
|
||||
http.Redirect(w, r, path, http.StatusFound)
|
||||
}
|
||||
|
||||
// redirectModalData is redirectModal plus an opaque data payload attached to
|
||||
// the flash (used for the "log in as" button after creating a user).
|
||||
func (s *Server) redirectModalData(w http.ResponseWriter, r *http.Request, path, msg, data string) {
|
||||
if c, err := r.Cookie("vpsmgr_admin_session"); err == nil {
|
||||
s.flash.SetWithData(c.Value, msg, "user_created", data)
|
||||
}
|
||||
http.Redirect(w, r, path, http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data pageData) {
|
||||
s.renderStatus(w, r, http.StatusOK, name, data)
|
||||
}
|
||||
|
||||
@@ -425,7 +425,7 @@ func TestAuditPageAndAPI(t *testing.T) {
|
||||
t.Fatalf("GET /audit/api = %d", rr.Code)
|
||||
}
|
||||
b := rr.Body.String()
|
||||
for _, want := range []string{`"actor":"000+alice"`, `"action":"power.stop"`, `"total":2`, `"more":false`} {
|
||||
for _, want := range []string{`"actor":"000+alice"`, `"action":"power.stop"`, `"total":3`, `"more":false`, `"actor":"000"`, `"action":"session.login"`} {
|
||||
if !strings.Contains(b, want) {
|
||||
t.Errorf("audit api missing %s:\n%s", want, b)
|
||||
}
|
||||
@@ -690,3 +690,145 @@ func TestAdminKeysSave(t *testing.T) {
|
||||
t.Error("admin key rows should no longer render an active checkbox")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginAs covers "log in as user": the admin POSTs /login-as and gets a
|
||||
// redirect to the user panel with a fresh, impersonated user-panel session
|
||||
// cookie, and the event is audited "000+<user>".
|
||||
func TestLoginAs(t *testing.T) {
|
||||
srv, d := newTestServer(t)
|
||||
setAdminPass(t, srv, "correct-horse-battery")
|
||||
h := srv.Handler()
|
||||
prefix := "/" + testAdminSecret
|
||||
userPrefix := "/UserSecRet99" // cfg.Panel.URLPath in newTestServer
|
||||
if _, err := d.CreateUser("alice", "x", "10.115.0.2", 1, 30001, 10000, 1, 1024, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cookie := adminLogin(t, h, prefix, "correct-horse-battery")
|
||||
|
||||
rr := doReq(t, h, http.MethodPost, prefix+"/login-as", url.Values{"name": {"alice"}}, cookie)
|
||||
if rr.Code != http.StatusFound {
|
||||
t.Fatalf("login-as = %d, want 302 (body %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
if loc := rr.Header().Get("Location"); loc != userPrefix+"/" {
|
||||
t.Fatalf("Location = %q, want %q", loc, userPrefix+"/")
|
||||
}
|
||||
var uc *http.Cookie
|
||||
for _, c := range rr.Result().Cookies() {
|
||||
if c.Name == "vpsmgr_session" {
|
||||
v := *c
|
||||
uc = &v
|
||||
}
|
||||
}
|
||||
if uc == nil {
|
||||
t.Fatal("no user-panel session cookie set")
|
||||
}
|
||||
if uc.Path != userPrefix {
|
||||
t.Errorf("cookie Path = %q, want %q", uc.Path, userPrefix)
|
||||
}
|
||||
u, imp, err := d.SessionWithFlag(uc.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("session lookup: %v", err)
|
||||
}
|
||||
if u.Name != "alice" || !imp {
|
||||
t.Errorf("session = user %q imp=%v, want alice imp=true", u.Name, imp)
|
||||
}
|
||||
rows, _ := d.ListAuditLog(0, 10)
|
||||
var found bool
|
||||
for _, r := range rows {
|
||||
if r.Actor == "000+alice" && r.Action == "session.login" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("audit rows = %+v, want 000+alice session.login", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginAsRequiresAdminAuth ensures /login-as is gated on an admin session.
|
||||
func TestLoginAsRequiresAdminAuth(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
setAdminPass(t, srv, "correct-horse-battery")
|
||||
h := srv.Handler()
|
||||
prefix := "/" + testAdminSecret
|
||||
rr := doReq(t, h, http.MethodPost, prefix+"/login-as", url.Values{"name": {"alice"}}, nil)
|
||||
if rr.Code != http.StatusFound || rr.Header().Get("Location") != prefix+"/login" {
|
||||
t.Fatalf("unauthenticated login-as = %d (loc %q), want redirect to login", rr.Code, rr.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlashCarriesData verifies the post-create flash delivers the username as
|
||||
// opaque data so the modal can offer "log in as this user".
|
||||
func TestFlashCarriesData(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
setAdminPass(t, srv, "correct-horse-battery")
|
||||
h := srv.Handler()
|
||||
prefix := "/" + testAdminSecret
|
||||
cookie := adminLogin(t, h, prefix, "correct-horse-battery")
|
||||
srv.flash.SetWithData(cookie.Value, "user: alice\npassword: xyz", "user_created", "alice")
|
||||
rr := doReq(t, h, http.MethodPost, prefix+"/flash", nil, cookie)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("/flash = %d", rr.Code)
|
||||
}
|
||||
b := rr.Body.String()
|
||||
for _, want := range []string{
|
||||
`"kind":"user_created"`,
|
||||
`"data":"alice"`,
|
||||
"\"msg\":\"user: alice\\npassword: xyz\"",
|
||||
} {
|
||||
if !strings.Contains(b, want) {
|
||||
t.Errorf("flash missing %s:\n%s", want, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminLoginWritesAudit verifies admin login is audited as "000".
|
||||
func TestAdminLoginWritesAudit(t *testing.T) {
|
||||
srv, d := newTestServer(t)
|
||||
setAdminPass(t, srv, "correct-horse-battery")
|
||||
h := srv.Handler()
|
||||
prefix := "/" + testAdminSecret
|
||||
adminLogin(t, h, prefix, "correct-horse-battery")
|
||||
rows, _ := d.ListAuditLog(0, 10)
|
||||
var found bool
|
||||
for _, r := range rows {
|
||||
if r.Actor == "000" && r.Action == "session.login" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("audit rows = %+v, want 000 session.login", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminOverviewLoginButtons checks the admin overview renders both the
|
||||
// per-user "login panel" button and the modal's "log in as this user" form.
|
||||
func TestAdminOverviewLoginButtons(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
html := srv.renderToString(t, "admin_overview.html", pageData{
|
||||
Prefix: "/" + testAdminSecret,
|
||||
Lang: langZh,
|
||||
Users: []userView{{Name: "alice", State: "Running", Status: "ready", SSHPort: "30001"}},
|
||||
})
|
||||
for _, want := range []string{
|
||||
"login-as", "登录面板", `name="name" value="alice"`,
|
||||
`id="loginAsForm"`, `id="loginAsName"`,
|
||||
} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Errorf("admin overview missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// renderToString executes a named admin template into a string for assertions.
|
||||
func (s *Server) renderToString(t *testing.T, name string, data pageData) string {
|
||||
t.Helper()
|
||||
tpl, err := s.templates()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var b strings.Builder
|
||||
if err := tpl.ExecuteTemplate(&b, name, data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
type flashEntry struct {
|
||||
msg string
|
||||
kind string
|
||||
data string
|
||||
ts time.Time
|
||||
}
|
||||
|
||||
@@ -26,6 +27,12 @@ const (
|
||||
)
|
||||
|
||||
func (f *flashStore) Set(token, msg, kind string) {
|
||||
f.SetWithData(token, msg, kind, "")
|
||||
}
|
||||
|
||||
// SetWithData stores a banner plus an optional opaque data payload (e.g. the
|
||||
// username of a freshly created user, so the modal can offer "log in as").
|
||||
func (f *flashStore) SetWithData(token, msg, kind, data string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if len(f.items) >= flashMaxItems {
|
||||
@@ -36,18 +43,18 @@ func (f *flashStore) Set(token, msg, kind string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
f.items[token] = &flashEntry{msg: msg, kind: kind, ts: time.Now()}
|
||||
f.items[token] = &flashEntry{msg: msg, kind: kind, data: data, ts: time.Now()}
|
||||
}
|
||||
|
||||
func (f *flashStore) Pop(token string) (msg, kind string, ok bool) {
|
||||
func (f *flashStore) Pop(token string) (msg, kind, data string, ok bool) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
e, ok := f.items[token]
|
||||
if !ok {
|
||||
return "", "toast", false
|
||||
return "", "toast", "", false
|
||||
}
|
||||
delete(f.items, token)
|
||||
return e.msg, e.kind, true
|
||||
return e.msg, e.kind, e.data, true
|
||||
}
|
||||
|
||||
func (f *flashStore) Clear(token string) {
|
||||
|
||||
@@ -277,6 +277,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w, token)
|
||||
_ = s.db.AddAuditLog("000", "session.login")
|
||||
s.redirect(w, r, s.p(""), "")
|
||||
return
|
||||
}
|
||||
@@ -306,12 +307,13 @@ func (s *Server) handleFlash(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
msg, kind, _ := s.flash.Pop(c.Value)
|
||||
msg, kind, data, _ := s.flash.Pop(c.Value)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(struct {
|
||||
Msg string `json:"msg"`
|
||||
Kind string `json:"kind"`
|
||||
}{msg, kind})
|
||||
Data string `json:"data"`
|
||||
}{msg, kind, data})
|
||||
}
|
||||
|
||||
// handleUserAdd creates a user with the CLI's Add logic and shows the full
|
||||
@@ -358,7 +360,8 @@ func (s *Server) handleUserAdd(w http.ResponseWriter, r *http.Request) {
|
||||
cred := "user: " + res.User.Name +
|
||||
"\npassword: " + res.Password +
|
||||
"\npanel: " + s.cfg.PanelURL("/"+s.cfg.Panel.URLPath)
|
||||
s.redirectModal(w, r, s.p(""), cred)
|
||||
// Carry the username as flash data so the modal can offer "log in as".
|
||||
s.redirectModalData(w, r, s.p(""), cred, res.User.Name)
|
||||
}
|
||||
|
||||
func (s *Server) handleUserDel(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -497,6 +500,46 @@ func (s *Server) handleAdminPass(w http.ResponseWriter, r *http.Request) {
|
||||
s.redirect(w, r, s.p(""), s.t(r, "admin_pass_changed"))
|
||||
}
|
||||
|
||||
// handleLoginAs ("log in as user" / impersonation) creates a user-panel
|
||||
// session for the given username and hands the browser its cookie, dropping the
|
||||
// operator straight into that user's panel — regardless of the user's password.
|
||||
// The admin's own session cookie is separate and untouched, so returning to the
|
||||
// admin panel works normally; impersonating another user later simply replaces
|
||||
// the user-panel cookie. The session is flagged impersonated, so the user
|
||||
// panel attributes its audit events "000+<user>" and shows a banner.
|
||||
func (s *Server) handleLoginAs(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
u, err := s.db.GetUserByName(name)
|
||||
if err != nil {
|
||||
s.redirect(w, r, s.p(""), "error: user not found")
|
||||
return
|
||||
}
|
||||
sess, err := s.db.CreateImpersonatedSession(u.ID, s.cfg.Panel.SessionDays)
|
||||
if err != nil {
|
||||
s.redirect(w, r, s.p(""), "error: "+err.Error())
|
||||
return
|
||||
}
|
||||
// Set the user panel's own session cookie (same name, path and flags as a
|
||||
// real login) so the browser becomes that user. Only the user-panel cookie
|
||||
// is written here; the admin cookie is left alone.
|
||||
userPrefix := "/" + s.cfg.Panel.URLPath
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "vpsmgr_session",
|
||||
Value: sess.Token,
|
||||
Path: userPrefix,
|
||||
MaxAge: s.cfg.Panel.SessionDays * 86400,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
_ = s.db.AddAuditLog("000+"+u.Name, "session.login")
|
||||
http.Redirect(w, r, userPrefix+"/", http.StatusFound)
|
||||
}
|
||||
|
||||
// parseMem parses a memory string ("512" or "1G") into MiB, mirroring the CLI.
|
||||
func parseMem(s string) (int, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
@@ -750,6 +793,7 @@ func (s *Server) handleIPv6PoolAdd(w http.ResponseWriter, r *http.Request) {
|
||||
s.redirect(w, r, s.p("/ipv6pool"), "ok: no new addresses")
|
||||
return
|
||||
}
|
||||
_ = s.db.AddAuditLog("000", "ipv6pool.add")
|
||||
s.redirect(w, r, s.p("/ipv6pool"), s.t(r, "pool_added", len(added)))
|
||||
}
|
||||
|
||||
@@ -764,6 +808,7 @@ func (s *Server) handleIPv6PoolDel(w http.ResponseWriter, r *http.Request) {
|
||||
s.redirect(w, r, s.p("/ipv6pool"), "error: "+err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.db.AddAuditLog("000", "ipv6pool.del")
|
||||
s.redirect(w, r, s.p("/ipv6pool"), s.t(r, "pool_removed", addr))
|
||||
}
|
||||
|
||||
|
||||
@@ -226,6 +226,7 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
|
||||
<form action="{{$.Prefix}}/power" method="post" class="inline"><input type="hidden" name="name" value="{{.Name}}"><input type="hidden" name="action" value="start"><button class="ok">{{if eq $.Lang "zh"}}启动{{else}}Start{{end}}</button></form>
|
||||
{{end}}
|
||||
<button onclick="quota('{{.Name}}','{{.QuotaCPU}}','{{.QuotaMem}}','{{.QuotaDisk}}','{{.BandwidthGB}}')">{{if eq $.Lang "zh"}}配额{{else}}Quota{{end}}</button>
|
||||
<form action="{{$.Prefix}}/login-as" method="post" class="inline"><input type="hidden" name="name" value="{{.Name}}"><button class="primary" title="{{if eq $.Lang "zh"}}以该用户身份登录用户面板{{else}}log in to this user's panel{{end}}">{{if eq $.Lang "zh"}}登录面板{{else}}Login{{end}}</button></form>
|
||||
<form action="{{$.Prefix}}/reset-panel-pass" method="post" class="inline"><input type="hidden" name="name" value="{{.Name}}"><button>{{if eq $.Lang "zh"}}重置密码{{else}}Reset pw{{end}}</button></form>
|
||||
<button class="danger" onclick="del('{{.Name}}')">{{if eq $.Lang "zh"}}删除{{else}}Delete{{end}}</button>
|
||||
</div>
|
||||
@@ -256,6 +257,10 @@ a.copy.copied { border-color: #86efac; background: #dcfce7; color: #15803d; }
|
||||
<div class="body" id="modalBody"></div>
|
||||
<div class="foot">
|
||||
<a class="copy" id="modalCopy" onclick="copyModal()">{{if eq .Lang "zh"}}复制{{else}}Copy{{end}}</a>
|
||||
<form action="{{.Prefix}}/login-as" method="post" id="loginAsForm" class="inline" hidden>
|
||||
<input type="hidden" name="name" id="loginAsName">
|
||||
<button class="primary" type="submit">{{if eq .Lang "zh"}}以此身份登录{{else}}Log in as this user{{end}}</button>
|
||||
</form>
|
||||
<button onclick="closeModal()">{{if eq .Lang "zh"}}关闭{{else}}Close{{end}}</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -316,7 +321,10 @@ var COPY_OK = {{if eq .Lang "zh"}}"已复制 ✓"{{else}}"Copied ✓"{{end}};
|
||||
var COPY_LBL = {{if eq .Lang "zh"}}"复制"{{else}}"Copy"{{end}};
|
||||
var CREATE_ING = {{if eq .Lang "zh"}}"创建中…"{{else}}"Creating…"{{end}};
|
||||
function openModal(msg){ document.getElementById('modalBody').textContent = msg; document.getElementById('modal').classList.add('open'); }
|
||||
function closeModal(){ document.getElementById('modal').classList.remove('open'); }
|
||||
function closeModal(){ document.getElementById('modal').classList.remove('open'); hideLoginAs(); }
|
||||
// The "log in as this user" button appears only in the post-create modal.
|
||||
function showLoginAs(name){ var f = document.getElementById('loginAsForm'); if(!f) return; document.getElementById('loginAsName').value = name || ''; f.hidden = !name; }
|
||||
function hideLoginAs(){ var f = document.getElementById('loginAsForm'); if(f) f.hidden = true; }
|
||||
function copyModal(){
|
||||
var b = document.getElementById('modalBody');
|
||||
var done = function(){
|
||||
@@ -458,7 +466,11 @@ function del(name){
|
||||
})();
|
||||
// consume flash banner once
|
||||
fetch(P + '/flash', {method:'POST'}).then(function(r){return r.json();}).then(function(j){
|
||||
if (j.msg) { if (j.kind === 'modal') openModal(j.msg); else alert(j.msg); }
|
||||
if (j.msg) {
|
||||
if (j.kind === 'modal') { openModal(j.msg); }
|
||||
else if (j.kind === 'user_created') { openModal(j.msg); showLoginAs(j.data); }
|
||||
else { alert(j.msg); }
|
||||
}
|
||||
});
|
||||
// Admin SSH-key management panel: floating modal, no page switch. Same
|
||||
// full-set save contract as the user panel (id > 0 updates, 0 adds, missing
|
||||
|
||||
@@ -55,7 +55,7 @@ func (d *DB) Close() error { return d.sql.Close() }
|
||||
// schemaVersion is the current schema version. Every migration in
|
||||
// migrations must be applied in order; Open refuses to start on a database
|
||||
// whose version is newer than this binary understands (downgrade protection).
|
||||
const schemaVersion = 10
|
||||
const schemaVersion = 11
|
||||
|
||||
// migrations are applied in order, each inside its own transaction. v1 is the
|
||||
// original schema (baseline); later versions only add/alter, never drop.
|
||||
@@ -221,6 +221,13 @@ var migrations = []struct {
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_admin_key_grants_user ON admin_key_grants(user_id)`,
|
||||
}},
|
||||
// v11: mark user sessions created by the operator ("log in as user"). Lets
|
||||
// the user panel tell an admin impersonation apart from the user's own
|
||||
// login, so audit events can be attributed "000+<user>". Existing rows are
|
||||
// normal (0).
|
||||
{11, []string{
|
||||
`ALTER TABLE sessions ADD COLUMN impersonated INTEGER NOT NULL DEFAULT 0`,
|
||||
}},
|
||||
}
|
||||
|
||||
// sqliteStr quotes a string literal for SQLite by doubling any single quote.
|
||||
|
||||
@@ -24,6 +24,19 @@ func hashToken(token string) string {
|
||||
}
|
||||
|
||||
func (d *DB) CreateSession(userID int64, days int) (*Session, error) {
|
||||
return d.createSession(userID, days, false)
|
||||
}
|
||||
|
||||
// CreateImpersonatedSession creates a user session on behalf of the operator
|
||||
// ("log in as user"). It is indistinguishable from a real login to the user
|
||||
// panel except for the impersonated flag, which the panel uses to attribute
|
||||
// audit events to the operator ("000+<user>") and to show a "logged in as"
|
||||
// banner.
|
||||
func (d *DB) CreateImpersonatedSession(userID int64, days int) (*Session, error) {
|
||||
return d.createSession(userID, days, true)
|
||||
}
|
||||
|
||||
func (d *DB) createSession(userID int64, days int, impersonated bool) (*Session, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, err
|
||||
@@ -33,8 +46,8 @@ func (d *DB) CreateSession(userID int64, days int) (*Session, error) {
|
||||
token := hex.EncodeToString(b)
|
||||
s := &Session{Token: token, UserID: userID,
|
||||
ExpiresAt: time.Now().Add(time.Duration(days) * 24 * time.Hour).Unix()}
|
||||
_, err := d.sql.Exec(`INSERT INTO sessions(token, user_id, expires_at) VALUES(?,?,?)`,
|
||||
hashToken(token), s.UserID, s.ExpiresAt)
|
||||
_, err := d.sql.Exec(`INSERT INTO sessions(token, user_id, expires_at, impersonated) VALUES(?,?,?,?)`,
|
||||
hashToken(token), s.UserID, s.ExpiresAt, b2i(impersonated))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -43,18 +56,30 @@ func (d *DB) CreateSession(userID int64, days int) (*Session, error) {
|
||||
|
||||
// SessionUser returns the user for a valid (unexpired) session token.
|
||||
func (d *DB) SessionUser(token string) (*User, error) {
|
||||
u, _, err := d.SessionWithFlag(token)
|
||||
return u, err
|
||||
}
|
||||
|
||||
// SessionWithFlag returns the user for a valid session token plus whether the
|
||||
// session was created by the operator (impersonation).
|
||||
func (d *DB) SessionWithFlag(token string) (*User, bool, error) {
|
||||
var userID int64
|
||||
var expiresAt int64
|
||||
err := d.sql.QueryRow(`SELECT user_id, expires_at FROM sessions WHERE token=?`, hashToken(token)).
|
||||
Scan(&userID, &expiresAt)
|
||||
var imp int
|
||||
err := d.sql.QueryRow(`SELECT user_id, expires_at, impersonated FROM sessions WHERE token=?`, hashToken(token)).
|
||||
Scan(&userID, &expiresAt, &imp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, false, err
|
||||
}
|
||||
if time.Now().Unix() > expiresAt {
|
||||
d.DeleteSession(token)
|
||||
return nil, errors.New("session expired")
|
||||
return nil, false, errors.New("session expired")
|
||||
}
|
||||
return d.GetUserByID(userID)
|
||||
u, err := d.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return u, imp != 0, nil
|
||||
}
|
||||
|
||||
func (d *DB) DeleteSession(token string) error {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestImpersonatedSession(t *testing.T) {
|
||||
d, err := Open(filepath.Join(t.TempDir(), "t.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
u, err := d.CreateUser("alice", "h", "10.115.0.2", 1, 30001, 10000, 1, 1024, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A normal login session is not impersonated.
|
||||
ns, err := d.CreateSession(u.ID, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gu, imp, err := d.SessionWithFlag(ns.Token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gu.ID != u.ID || imp {
|
||||
t.Errorf("normal session: user=%d imp=%v, want user=%d imp=false", gu.ID, imp, u.ID)
|
||||
}
|
||||
|
||||
// An operator "log in as user" session carries the impersonated flag.
|
||||
is, err := d.CreateImpersonatedSession(u.ID, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gu, imp, err = d.SessionWithFlag(is.Token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gu.ID != u.ID || !imp {
|
||||
t.Errorf("impersonated session: user=%d imp=%v, want imp=true", gu.ID, imp)
|
||||
}
|
||||
|
||||
// The compat accessor still resolves the user either way.
|
||||
if _, err := d.SessionUser(ns.Token); err != nil {
|
||||
t.Errorf("SessionUser(normal): %v", err)
|
||||
}
|
||||
if _, err := d.SessionUser(is.Token); err != nil {
|
||||
t.Errorf("SessionUser(impersonated): %v", err)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,13 @@ func itoa(n int) string { return strconv.Itoa(n) }
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const userKey ctxKey = 0
|
||||
const (
|
||||
userKey ctxKey = 0
|
||||
// impersonatedKey marks a session created by the operator ("log in as
|
||||
// user"). When set, audit events are attributed "000+<user>" and the panel
|
||||
// shows a "logged in as" banner.
|
||||
impersonatedKey ctxKey = 1
|
||||
)
|
||||
|
||||
// loginDummyHash is compared against when the username is unknown so that a
|
||||
// login attempt takes the same time whether or not the account exists,
|
||||
@@ -60,16 +66,35 @@ func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
s.redirect(w, r, s.p("/login"), "")
|
||||
return
|
||||
}
|
||||
u, err := s.db.SessionUser(c.Value)
|
||||
u, imp, err := s.db.SessionWithFlag(c.Value)
|
||||
if err != nil {
|
||||
s.clearSessionCookie(w)
|
||||
s.redirect(w, r, s.p("/login"), "")
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), userKey, u)))
|
||||
ctx := context.WithValue(r.Context(), userKey, u)
|
||||
ctx = context.WithValue(ctx, impersonatedKey, imp)
|
||||
next(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
// isImpersonated reports whether the current session was created by the
|
||||
// operator ("log in as user").
|
||||
func (s *Server) isImpersonated(r *http.Request) bool {
|
||||
v, _ := r.Context().Value(impersonatedKey).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
// auditActor returns the actor name for an audit event: the plain username for
|
||||
// a user's own action, or "000+<username>" when the operator is acting as that
|
||||
// user (impersonation). Mirrors the admin panel's "000+" convention.
|
||||
func (s *Server) auditActor(r *http.Request, name string) string {
|
||||
if s.isImpersonated(r) {
|
||||
return "000+" + name
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// requirePost rejects everything but POST so no state-changing action can be
|
||||
// triggered by a top-level GET navigation (CSRF via SameSite=Lax). A wrong
|
||||
// method gets the same bare 404 as any other wrong path — never a 405, which
|
||||
@@ -146,6 +171,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
sess, err := s.db.CreateSession(u.ID, s.cfg.Panel.SessionDays)
|
||||
if err == nil {
|
||||
s.setSessionCookie(w, sess.Token)
|
||||
_ = s.db.AddAuditLog(u.Name, "session.login")
|
||||
s.redirect(w, r, s.p(""), "")
|
||||
return
|
||||
}
|
||||
@@ -166,7 +192,10 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) {
|
||||
u := s.currentUser(r)
|
||||
s.render(w, r, "overview.html", s.buildData(u, "", ""))
|
||||
d := s.buildData(u, "", "")
|
||||
d.Impersonated = s.isImpersonated(r)
|
||||
d.AdminPrefix = "/" + s.cfg.Panel.AdminPath
|
||||
s.render(w, r, "overview.html", d)
|
||||
}
|
||||
|
||||
func (s *Server) handlePower(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -180,7 +209,7 @@ func (s *Server) handlePower(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.mgr.Power(u.Name, action); err != nil {
|
||||
msg = "error: " + err.Error()
|
||||
} else {
|
||||
_ = s.db.AddAuditLog(u.Name, "power."+action)
|
||||
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "power."+action)
|
||||
msg = "ok: " + action
|
||||
}
|
||||
s.redirect(w, r, s.p(""), msg)
|
||||
@@ -201,7 +230,7 @@ func (s *Server) handleReinstall(w http.ResponseWriter, r *http.Request) {
|
||||
s.redirect(w, r, s.p(""), "error: "+err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.db.AddAuditLog(u.Name, "reinstall")
|
||||
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "reinstall")
|
||||
s.redirectModal(w, r, s.p(""), s.t(r, "reinstall_done", pass))
|
||||
}
|
||||
|
||||
@@ -231,7 +260,7 @@ func (s *Server) handlePanelPassword(w http.ResponseWriter, r *http.Request) {
|
||||
s.redirect(w, r, s.p(""), "error: "+err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.db.AddAuditLog(u.Name, "passwd.change")
|
||||
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "passwd.change")
|
||||
s.redirect(w, r, s.p(""), "ok: panel password changed")
|
||||
}
|
||||
|
||||
@@ -244,7 +273,7 @@ func (s *Server) handleRootReset(w http.ResponseWriter, r *http.Request) {
|
||||
s.redirect(w, r, s.p(""), "error: "+err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.db.AddAuditLog(u.Name, "reset_root_password")
|
||||
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "reset_root_password")
|
||||
s.redirectModal(w, r, s.p(""), s.t(r, "new_root_password", pass))
|
||||
}
|
||||
|
||||
@@ -259,7 +288,7 @@ func (s *Server) handleDomainAdd(w http.ResponseWriter, r *http.Request) {
|
||||
s.redirect(w, r, s.p(""), "error: "+err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.db.AddAuditLog(u.Name, "domain_update")
|
||||
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "domain_update")
|
||||
s.redirect(w, r, s.p(""), "ok: domain added")
|
||||
}
|
||||
|
||||
@@ -296,7 +325,7 @@ func (s *Server) handleDomainUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
s.redirect(w, r, s.p(""), "ok: no changes")
|
||||
return
|
||||
}
|
||||
_ = s.db.AddAuditLog(u.Name, "domain_update")
|
||||
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "domain_update")
|
||||
s.redirect(w, r, s.p(""), "ok: domain settings saved")
|
||||
}
|
||||
|
||||
@@ -310,7 +339,7 @@ func (s *Server) handleDomainDel(w http.ResponseWriter, r *http.Request) {
|
||||
s.redirect(w, r, s.p(""), "error: "+err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.db.AddAuditLog(u.Name, "domain_update")
|
||||
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "domain_update")
|
||||
s.redirect(w, r, s.p(""), "ok: domain removed")
|
||||
}
|
||||
|
||||
@@ -347,6 +376,7 @@ func (s *Server) handleInitScript(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return
|
||||
}
|
||||
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "init_script")
|
||||
if jsonReq {
|
||||
writeInitScriptJSON(w, true, "")
|
||||
} else {
|
||||
@@ -404,7 +434,7 @@ func (s *Server) handleSnapshot(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.mgr.SnapshotCreate(u.Name); err != nil {
|
||||
msg = "error: " + err.Error()
|
||||
} else {
|
||||
_ = s.db.AddAuditLog(u.Name, "snapshot.create")
|
||||
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "snapshot.create")
|
||||
msg = "ok: snapshot created"
|
||||
}
|
||||
s.redirect(w, r, s.p(""), msg)
|
||||
@@ -422,7 +452,7 @@ func (s *Server) handleSnapshotDel(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.mgr.SnapshotDelete(u.Name, name); err != nil {
|
||||
msg = "error: " + err.Error()
|
||||
} else {
|
||||
_ = s.db.AddAuditLog(u.Name, "snapshot.delete")
|
||||
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "snapshot.delete")
|
||||
msg = "ok: snapshot deleted"
|
||||
}
|
||||
s.redirect(w, r, s.p(""), msg)
|
||||
@@ -441,7 +471,7 @@ func (s *Server) handleSnapshotRestore(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.mgr.SnapshotRestore(u.Name, name); err != nil {
|
||||
msg = "error: " + err.Error()
|
||||
} else {
|
||||
_ = s.db.AddAuditLog(u.Name, "snapshot.restore")
|
||||
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "snapshot.restore")
|
||||
msg = "ok: snapshot restored"
|
||||
}
|
||||
s.redirect(w, r, s.p(""), msg)
|
||||
@@ -473,7 +503,7 @@ func (s *Server) handleSSHKeys(w http.ResponseWriter, r *http.Request) {
|
||||
writeSSHKeys(w, false, err.Error(), "", nil, nil)
|
||||
return
|
||||
}
|
||||
_ = s.db.AddAuditLog(u.Name, "ssh_keys")
|
||||
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "ssh_keys")
|
||||
// Apply the selection immediately. Admin keys are fully synced (activated
|
||||
// ones written with the AdminKeyMarker, stale/unactivated ones removed), so
|
||||
// this runs even when nothing is active — deactivating an admin key must
|
||||
@@ -550,7 +580,7 @@ func (s *Server) handleNotesReset(w http.ResponseWriter, r *http.Request) {
|
||||
writeNotesJSON(w, false, "storage error", true, "")
|
||||
return
|
||||
}
|
||||
_ = s.db.AddAuditLog(u.Name, "notes_reset")
|
||||
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "notes_reset")
|
||||
writeNotesJSON(w, true, "", false, "")
|
||||
}
|
||||
|
||||
|
||||
@@ -160,6 +160,8 @@ type pageData struct {
|
||||
SnapshotLimit int // configured per-container snapshot cap (for display)
|
||||
SSHKeys []sshKeyRow
|
||||
AdminSSHKeys []sshKeyRow // operator's own public keys, shown read-only
|
||||
Impersonated bool // operator "log in as user": show a banner
|
||||
AdminPrefix string // admin panel path, for the banner's return link
|
||||
MaxNotesPlaintext int // sticky-notes plaintext byte cap (client-side check)
|
||||
}
|
||||
|
||||
|
||||
@@ -797,8 +797,16 @@ func TestDomainAddLogsAudit(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].Actor != "alice" || rows[0].Action != "domain_update" {
|
||||
t.Errorf("audit rows = %+v", rows)
|
||||
// The login also writes a session.login row; assert the domain_update row
|
||||
// is present (and attributed to the acting user).
|
||||
var found bool
|
||||
for _, r := range rows {
|
||||
if r.Actor == "alice" && r.Action == "domain_update" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("audit rows = %+v, want a domain_update row for alice", rows)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -890,13 +898,16 @@ func TestSnapshotRoutesRegistered(t *testing.T) {
|
||||
t.Fatalf("POST /snapshot-restore = %d, want 302", rr.Code)
|
||||
}
|
||||
|
||||
// No audit rows: all snapshot calls failed before any successful action.
|
||||
// No audit rows for the snapshot ops: every snapshot call failed before
|
||||
// any successful action (the login row is expected).
|
||||
rows, err := d.ListAuditLog(0, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("audit rows = %+v, want none (all snapshot ops failed)", rows)
|
||||
for _, r := range rows {
|
||||
if strings.HasPrefix(r.Action, "snapshot.") {
|
||||
t.Errorf("failed snapshot op wrote an audit row: %+v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1427,3 +1438,88 @@ func jsonQuote(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// TestUserLoginWritesAudit verifies a successful user login records a
|
||||
// session.login audit event (newly added audit coverage).
|
||||
func TestUserLoginWritesAudit(t *testing.T) {
|
||||
srv, d := newTestServer(t)
|
||||
h := srv.Handler()
|
||||
prefix := "/" + testSecret
|
||||
hash, _ := pw.Hash("pw")
|
||||
if _, err := d.CreateUser("alice", hash, "10.42.0.2", 1, 30001, 10000, 1, 1024, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr := doReq(t, h, http.MethodPost, prefix+"/login",
|
||||
url.Values{"username": {"alice"}, "password": {"pw"}}, nil)
|
||||
if rr.Code != http.StatusFound {
|
||||
t.Fatalf("login = %d, want 302", rr.Code)
|
||||
}
|
||||
rows, _ := d.ListAuditLog(0, 10)
|
||||
var found bool
|
||||
for _, r := range rows {
|
||||
if r.Actor == "alice" && r.Action == "session.login" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("audit rows = %+v, want alice session.login", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImpersonatedSessionAuditActor verifies that a user-panel action carried
|
||||
// out under an operator-created session is attributed "000+<user>" (not the
|
||||
// user's own name).
|
||||
func TestImpersonatedSessionAuditActor(t *testing.T) {
|
||||
srv, d := newTestServer(t)
|
||||
h := srv.Handler()
|
||||
prefix := "/" + testSecret
|
||||
hash, _ := pw.Hash("pw")
|
||||
u, err := d.CreateUser("alice", hash, "10.42.0.2", 1, 30001, 10000, 1, 1024, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sess, err := d.CreateImpersonatedSession(u.ID, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cookie := &http.Cookie{Name: "vpsmgr_session", Value: sess.Token, Path: prefix}
|
||||
|
||||
rr := postJSON(t, h, prefix+"/init-script", `{"script":"#!/bin/bash\necho hi"}`, cookie)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("init-script save = %d, want 200", rr.Code)
|
||||
}
|
||||
rows, _ := d.ListAuditLog(0, 10)
|
||||
var found bool
|
||||
for _, r := range rows {
|
||||
if r.Action == "init_script" && r.Actor == "000+alice" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("audit rows = %+v, want init_script attributed 000+alice", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOverviewImpersonationBanner verifies the "logged in as" banner renders
|
||||
// only when the session is impersonated, with a link back to the admin panel.
|
||||
func TestOverviewImpersonationBanner(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
html := srv.renderToString(t, "overview.html", pageData{
|
||||
User: &db.User{Name: "alice"}, Prefix: "/" + testSecret,
|
||||
Impersonated: true, AdminPrefix: "/Adm1n", Lang: langZh,
|
||||
})
|
||||
for _, want := range []string{"管理员代客登录中", "返回管理员面板", "/Adm1n"} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Errorf("impersonated overview missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
plain := srv.renderToString(t, "overview.html", pageData{
|
||||
User: &db.User{Name: "alice"}, Prefix: "/" + testSecret,
|
||||
})
|
||||
for _, absent := range []string{`class="impbanner"`, "管理员代客登录中"} {
|
||||
if strings.Contains(plain, absent) {
|
||||
t.Errorf("normal overview should not render %q", absent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,10 @@ input.bad{border-color:var(--danger);outline:1px solid var(--danger)}
|
||||
.frow label{flex:0 0 4.2rem}
|
||||
label{font-size:.84rem;color:var(--muted)}
|
||||
.err{background:var(--err-bg);color:var(--err-fg);border:1px solid var(--danger);padding:.5rem .7rem;border-radius:8px;margin-bottom:.8rem;font-size:.87rem;word-break:break-all}
|
||||
.impbanner{background:var(--violet-bg);color:var(--violet-fg);border:1px solid var(--violet-border);padding:.55rem .8rem;border-radius:8px;margin-bottom:.8rem;font-size:.87rem;display:flex;align-items:center;gap:.6rem;flex-wrap:wrap}
|
||||
.impbanner b{color:var(--violet-fg)}
|
||||
.impbanner .impback{margin-left:auto}
|
||||
.impbanner a{color:var(--accent);text-decoration:underline;font-weight:600}
|
||||
a{color:var(--accent);text-decoration:none}
|
||||
.domtable{width:100%;border-collapse:collapse;font-size:.85rem;margin:.2rem 0 .6rem}
|
||||
.domtable th,.domtable td{text-align:left;padding:.3rem .4rem;border-bottom:1px solid var(--border);vertical-align:middle}
|
||||
@@ -161,6 +165,13 @@ footer a{color:var(--muted);text-decoration:underline}
|
||||
</div>
|
||||
</div>
|
||||
{{if .Err}}<div class="err">{{.Err}}</div>{{end}}
|
||||
{{if .Impersonated}}
|
||||
<div class="impbanner">
|
||||
<b>{{if eq .Lang "zh"}}管理员代客登录中{{else}}Operator is logged in as you{{end}}</b>
|
||||
<span class="muted" style="color:inherit">{{if eq .Lang "zh"}}当前会话由管理员以你的身份建立。{{else}}This session was opened by the administrator acting as you.{{end}}</span>
|
||||
<span class="impback"><a href="{{.AdminPrefix}}">{{if eq .Lang "zh"}}返回管理员面板{{else}}Back to admin panel{{end}}</a></span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>{{if eq .Lang "zh"}}概览{{else}}Overview{{end}}<span> · {{.User.Name}}</span></h2>
|
||||
|
||||
Reference in new issue
Block a user