481 lines
17 KiB
Go
481 lines
17 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|||
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||
|
|
|
||
|
|
package admin
|
||
|
|
|
||
|
|
import (
|
||
|
|
"fmt"
|
||
|
|
"html"
|
||
|
|
"log/slog"
|
||
|
|
"net/http"
|
||
|
|
"net/netip"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/audit"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/backup"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/config"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/post"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/ratelimit"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/session"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/store"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/templates"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/tokens"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/users"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/web"
|
||
|
|
"sourcedock.dev/petrbalvin/volumen/internal/webhooks"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Content is the content surface the admin uses: every post, one post by
|
||
|
|
// slug, the write and delete paths, the revision archive and the media
|
||
|
|
// library. It is an interface rather than *store.Store so the handlers can
|
||
|
|
// be tested against a fake and a second backend stays conceivable.
|
||
|
|
type Content interface {
|
||
|
|
All() []*post.Post
|
||
|
|
Find(slug, lang string) *post.Post
|
||
|
|
Save(p *post.Post) (*post.Post, error)
|
||
|
|
Delete(slug, lang string) (*post.Post, bool, error)
|
||
|
|
Undelete(slug string) *post.Post
|
||
|
|
Revisions(slug string) []store.Revision
|
||
|
|
RevisionContent(slug, name string) string
|
||
|
|
RestoreRevision(p *post.Post, name string) *post.Post
|
||
|
|
InvalidateCache()
|
||
|
|
StoreUpload(originalName string, data []byte) (string, error)
|
||
|
|
MediaPath(name string) (string, error)
|
||
|
|
DeleteMedia(url string) bool
|
||
|
|
ListMedia() []store.Media
|
||
|
|
}
|
||
|
|
|
||
|
|
// Deps are the shared services the admin UI needs.
|
||
|
|
type Deps struct {
|
||
|
|
Config *config.Config
|
||
|
|
Store Content
|
||
|
|
Users *users.Users
|
||
|
|
Templates *templates.Store
|
||
|
|
Tokens *tokens.Store
|
||
|
|
Audit *audit.Log
|
||
|
|
LoginLim *ratelimit.LoginLimiter
|
||
|
|
Sessions *session.Store
|
||
|
|
Webhooks *webhooks.Manager
|
||
|
|
|
||
|
|
// PreviewKey signs the shareable preview links: the session secret
|
||
|
|
// the app layer resolved, from [admin].session_key or from the
|
||
|
|
// secret.key file it generated, so a default deployment offers
|
||
|
|
// preview links the way the configuration documents it. Empty means
|
||
|
|
// no link can be signed and none is offered.
|
||
|
|
PreviewKey string
|
||
|
|
|
||
|
|
// WebhooksFile is the admin-managed hook store the settings forms
|
||
|
|
// rewrite, and StaticWebhooks the hooks that came from config.toml
|
||
|
|
// and are read-only here. Together they are what the manager
|
||
|
|
// delivers; every mutation re-saves the file and refreshes the
|
||
|
|
// manager with the merge of the two.
|
||
|
|
WebhooksFile string
|
||
|
|
StaticWebhooks []webhooks.Webhook
|
||
|
|
|
||
|
|
Version string
|
||
|
|
OnEvent func(event string, payload map[string]any)
|
||
|
|
Backup backup.Options
|
||
|
|
|
||
|
|
// CheckUpdate returns the latest available version ("" when none),
|
||
|
|
// and SelfUpdate replaces the running binary; both are wired by the
|
||
|
|
// CLI layer and may be nil.
|
||
|
|
CheckUpdate func() (string, error)
|
||
|
|
SelfUpdate func() (target string, err error)
|
||
|
|
|
||
|
|
// UpdateInfo returns the newer version for the update banner, or ""
|
||
|
|
// when the running version is current.
|
||
|
|
UpdateInfo func() string
|
||
|
|
}
|
||
|
|
|
||
|
|
// Admin serves /admin routes.
|
||
|
|
type Admin struct {
|
||
|
|
deps Deps
|
||
|
|
renderer *Renderer
|
||
|
|
|
||
|
|
// trustedProxies are the peers whose X-Forwarded-For is believed.
|
||
|
|
// The configuration is validated before the admin is built, so a
|
||
|
|
// parse failure here cannot happen.
|
||
|
|
trustedProxies []netip.Prefix
|
||
|
|
}
|
||
|
|
|
||
|
|
// New builds the admin handler.
|
||
|
|
func New(deps Deps) (*Admin, error) {
|
||
|
|
renderer, err := NewRenderer()
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
trusted, err := deps.Config.TrustedProxyPrefixes()
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if !deps.Config.Server.TrustProxy {
|
||
|
|
trusted = nil
|
||
|
|
}
|
||
|
|
return &Admin{deps: deps, renderer: renderer, trustedProxies: trusted}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// SetUpdateHooks wires the version-check callbacks after construction.
|
||
|
|
func (a *Admin) SetUpdateHooks(check func() (string, error), selfUpdate func() (string, error)) {
|
||
|
|
a.deps.CheckUpdate = check
|
||
|
|
a.deps.SelfUpdate = selfUpdate
|
||
|
|
a.deps.UpdateInfo = func() string {
|
||
|
|
latest, err := check()
|
||
|
|
if err != nil || latest == "" || latest == a.deps.Version {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
return latest
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handler returns the admin route tree.
|
||
|
|
func (a *Admin) Handler() http.Handler {
|
||
|
|
mux := http.NewServeMux()
|
||
|
|
mux.HandleFunc("GET /admin", func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||
|
|
})
|
||
|
|
// The interface sheets and fonts: public by design (the login page
|
||
|
|
// needs them before any session), confined to the embedded set.
|
||
|
|
mux.HandleFunc("GET /admin/assets/", web.AssetHandler)
|
||
|
|
mux.HandleFunc("GET /admin/login", a.handleLoginForm)
|
||
|
|
mux.HandleFunc("POST /admin/login", a.handleLogin)
|
||
|
|
mux.HandleFunc("GET /admin/twofactor", a.handleTwofactorForm)
|
||
|
|
mux.HandleFunc("POST /admin/twofactor", a.handleTwofactor)
|
||
|
|
mux.HandleFunc("POST /admin/logout", a.handleLogout)
|
||
|
|
a.registerSetupRoutes(mux)
|
||
|
|
a.registerPostRoutes(mux)
|
||
|
|
a.registerSettingsRoutes(mux)
|
||
|
|
a.registerMediaRoutes(mux)
|
||
|
|
// The subtree catch-all answers any /admin path no route above claims,
|
||
|
|
// so a mistyped URL meets the shell's own 404 page rather than the
|
||
|
|
// engine's bare text. It is the least specific pattern, so every
|
||
|
|
// registered route still wins, and it sits behind requireLogin so an
|
||
|
|
// anonymous visitor is sent to the sign-in screen first.
|
||
|
|
mux.HandleFunc("/admin/", a.requireLogin(a.handleNotFound))
|
||
|
|
return mux
|
||
|
|
}
|
||
|
|
|
||
|
|
// handleNotFound renders the admin 404 page inside the shell. Nothing
|
||
|
|
// about the request reaches the page beyond the interface strings, so the
|
||
|
|
// answer leaks nothing and carries the honest status.
|
||
|
|
func (a *Admin) handleNotFound(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||
|
|
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
a.renderPage(w, r, "notfound.html", a.pageData(r), http.StatusNotFound)
|
||
|
|
}
|
||
|
|
|
||
|
|
// record appends an audit entry for one request, filling in the acting
|
||
|
|
// user and the client address so the log can answer "who, from where".
|
||
|
|
func (a *Admin) record(r *http.Request, action, resource string, detail map[string]any) {
|
||
|
|
a.deps.Audit.Record(audit.Entry{
|
||
|
|
User: a.currentUser(r),
|
||
|
|
Action: action,
|
||
|
|
Resource: resource,
|
||
|
|
Detail: detail,
|
||
|
|
IP: a.clientIP(r),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// clientIP resolves the request origin with proxy awareness.
|
||
|
|
func (a *Admin) clientIP(r *http.Request) string {
|
||
|
|
return web.ClientIP(r, a.trustedProxies)
|
||
|
|
}
|
||
|
|
|
||
|
|
// lang resolves the interface language for one request: the account's
|
||
|
|
// choice first, then the language cookie the choice set for the login
|
||
|
|
// screen, then the site language when it is one the UI ships, and
|
||
|
|
// English otherwise.
|
||
|
|
func (a *Admin) lang(r *http.Request, record *users.User) string {
|
||
|
|
if record != nil && record.Language != "" {
|
||
|
|
return record.Language
|
||
|
|
}
|
||
|
|
if c, err := r.Cookie(i18n.Cookie); err == nil {
|
||
|
|
if lang := i18n.Normalize(c.Value); lang != "" {
|
||
|
|
return lang
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if lang := i18n.Normalize(a.deps.Config.Site.Language); lang != "" {
|
||
|
|
return lang
|
||
|
|
}
|
||
|
|
return "en"
|
||
|
|
}
|
||
|
|
|
||
|
|
// theme resolves the colour scheme for one request: the account's
|
||
|
|
// choice first, then the theme cookie the choice set for the login
|
||
|
|
// screen, then the default scheme.
|
||
|
|
func (a *Admin) theme(r *http.Request, record *users.User) string {
|
||
|
|
if record != nil && record.Theme != "" {
|
||
|
|
if web.ValidTheme(record.Theme) {
|
||
|
|
return record.Theme
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if c, err := r.Cookie(web.ThemeCookie); err == nil && web.ValidTheme(c.Value) {
|
||
|
|
return c.Value
|
||
|
|
}
|
||
|
|
return web.DefaultTheme
|
||
|
|
}
|
||
|
|
|
||
|
|
// langFor resolves the interface language from the request alone,
|
||
|
|
// without a page context: the signed-in account's choice, the language
|
||
|
|
// cookie, the site language, then English.
|
||
|
|
func (a *Admin) langFor(r *http.Request) string {
|
||
|
|
return a.lang(r, a.deps.Users.Find(session.FromContext(r.Context()).Get("user")))
|
||
|
|
}
|
||
|
|
|
||
|
|
// tr translates an admin interface message in the request's language.
|
||
|
|
func (a *Admin) tr(r *http.Request, s string) string {
|
||
|
|
return i18n.Admin.T(a.langFor(r), s)
|
||
|
|
}
|
||
|
|
|
||
|
|
// trf translates an admin interface message with one value.
|
||
|
|
func (a *Admin) trf(r *http.Request, s, arg string) string {
|
||
|
|
return i18n.Admin.Tf(a.langFor(r), s, arg)
|
||
|
|
}
|
||
|
|
|
||
|
|
// trf2 translates an admin interface message with two values.
|
||
|
|
func (a *Admin) trf2(r *http.Request, s, first, second string) string {
|
||
|
|
return i18n.Admin.Tf2(a.langFor(r), s, first, second)
|
||
|
|
}
|
||
|
|
|
||
|
|
// pageData builds the shared template context for one request.
|
||
|
|
func (a *Admin) pageData(r *http.Request) *PageData {
|
||
|
|
sess := session.FromContext(r.Context())
|
||
|
|
username := sess.Get("user")
|
||
|
|
record := a.deps.Users.Find(username)
|
||
|
|
data := &PageData{
|
||
|
|
Config: a.deps.Config,
|
||
|
|
Path: r.URL.Path,
|
||
|
|
CSPNonce: web.Nonce(r.Context()),
|
||
|
|
Version: a.deps.Version,
|
||
|
|
Lang: a.lang(r, record),
|
||
|
|
Theme: a.theme(r, record),
|
||
|
|
CurrentUser: username,
|
||
|
|
CurrentUserRecord: record,
|
||
|
|
UsersExist: a.deps.Users.Any(),
|
||
|
|
CSRFToken: CSRFToken(sess),
|
||
|
|
IsLogin: strings.HasPrefix(r.URL.Path, "/admin/login") || r.URL.Path == "/admin/twofactor",
|
||
|
|
IsSetup: r.URL.Path == "/admin/setup",
|
||
|
|
NavPosts: r.URL.Path == "/admin/" || r.URL.Path == "/admin",
|
||
|
|
NavNew: r.URL.Path == "/admin/posts/new",
|
||
|
|
NavImport: r.URL.Path == "/admin/posts/import",
|
||
|
|
NavMedia: strings.HasPrefix(r.URL.Path, "/admin/media"),
|
||
|
|
NavSettings: strings.HasPrefix(r.URL.Path, "/admin/settings"),
|
||
|
|
}
|
||
|
|
if a.deps.UpdateInfo != nil {
|
||
|
|
data.UpdateAvailable = a.deps.UpdateInfo()
|
||
|
|
}
|
||
|
|
if record != nil {
|
||
|
|
data.IsAuthenticated = true
|
||
|
|
data.CurrentRole = record.Role
|
||
|
|
data.DisplayName = record.Name
|
||
|
|
data.UserPhoto = record.Photo
|
||
|
|
if data.DisplayName == "" {
|
||
|
|
data.DisplayName = record.Username
|
||
|
|
}
|
||
|
|
data.UserInitial = firstUpper(data.DisplayName, "?")
|
||
|
|
}
|
||
|
|
return data
|
||
|
|
}
|
||
|
|
|
||
|
|
// templateEscape neutralises the characters that would end an HTML
|
||
|
|
// comment or open a tag inside one.
|
||
|
|
func templateEscape(s string) string {
|
||
|
|
s = strings.ReplaceAll(s, "--", "- -")
|
||
|
|
return html.EscapeString(s)
|
||
|
|
}
|
||
|
|
|
||
|
|
// renderPage executes an admin page with HTTP semantics.
|
||
|
|
func (a *Admin) renderPage(w http.ResponseWriter, r *http.Request, page string, data *PageData, status int) {
|
||
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
|
|
w.WriteHeader(status)
|
||
|
|
if err := a.renderer.Render(r.Context(), w, page, data); err != nil {
|
||
|
|
// The status line is already sent, and the page is the user's
|
||
|
|
// only signal, so it carries a note rather than nothing. The
|
||
|
|
// text is escaped: an error message quoting content must not
|
||
|
|
// close the comment and inject markup.
|
||
|
|
fmt.Fprintf(w, "<!-- template error: %s -->", templateEscape(err.Error()))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *Admin) handleLoginForm(w http.ResponseWriter, r *http.Request) {
|
||
|
|
sess := session.FromContext(r.Context())
|
||
|
|
if sess.Get("user") != "" {
|
||
|
|
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
// A session that already answered the password sits halfway in: the
|
||
|
|
// second step is where it belongs, not the first one again.
|
||
|
|
if sess.Get("totp_user") != "" {
|
||
|
|
http.Redirect(w, r, "/admin/twofactor", http.StatusSeeOther)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
// A deployment with no accounts is one that has not been set up
|
||
|
|
// yet: the login screen would only be a door with nothing behind
|
||
|
|
// it, so the first visit goes to the wizard instead. It is a redirect,
|
||
|
|
// not a rewrite, so the wizard has its own honest URL.
|
||
|
|
if needed, broken := a.setupNeeded(); needed && broken == nil {
|
||
|
|
http.Redirect(w, r, "/admin/setup", http.StatusSeeOther)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
a.renderPage(w, r, "login.html", a.pageData(r), http.StatusOK)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if err := r.ParseForm(); err != nil {
|
||
|
|
http.Error(w, "bad form", http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
ip := a.clientIP(r)
|
||
|
|
a.deps.LoginLim.Record(ip)
|
||
|
|
if blocked, retryAfter := a.deps.LoginLim.Blocked(ip); blocked {
|
||
|
|
data := a.pageData(r)
|
||
|
|
data.Error = i18n.Admin.N(a.lang(r, nil), "login.seconds", retryAfter)
|
||
|
|
data.RetryAfter = retryAfter
|
||
|
|
a.renderPage(w, r, "login.html", data, http.StatusTooManyRequests)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
sess := session.FromContext(r.Context())
|
||
|
|
if !ValidateCSRF(r, sess) {
|
||
|
|
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
username := r.PostFormValue("username")
|
||
|
|
password := r.PostFormValue("password")
|
||
|
|
if user := a.deps.Users.Authenticate(username, password); user != nil {
|
||
|
|
// An account with the second factor answers one more question
|
||
|
|
// before it is in: the session holds the half-way name and no
|
||
|
|
// user, so nothing behind requireLogin opens yet.
|
||
|
|
if user.TotpSecret != "" {
|
||
|
|
sess.Set("totp_user", user.Username)
|
||
|
|
sess.Set("totp_at", strconv.FormatInt(time.Now().Unix(), 10))
|
||
|
|
http.Redirect(w, r, "/admin/twofactor", http.StatusSeeOther)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
sess.Set("user", user.Username)
|
||
|
|
sess.Set("pv", sessionFingerprint(user.PasswordHash))
|
||
|
|
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
data := a.pageData(r)
|
||
|
|
data.Error = data.Tr("Invalid username or password.")
|
||
|
|
a.renderPage(w, r, "login.html", data, http.StatusUnauthorized)
|
||
|
|
}
|
||
|
|
|
||
|
|
// twofactorWindow bounds how long a password already answered may wait
|
||
|
|
// for its code before the whole sign-in starts over.
|
||
|
|
const twofactorWindow = 10 * time.Minute
|
||
|
|
|
||
|
|
// handleTwofactorForm shows the code prompt while a session holds a
|
||
|
|
// password-verified name; anything else goes back to the first step.
|
||
|
|
func (a *Admin) handleTwofactorForm(w http.ResponseWriter, r *http.Request) {
|
||
|
|
sess := session.FromContext(r.Context())
|
||
|
|
if sess.Get("user") != "" {
|
||
|
|
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if !a.twofactorPending(sess) {
|
||
|
|
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
a.renderPage(w, r, "twofactor.html", a.pageData(r), http.StatusOK)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *Admin) twofactorPending(sess *session.Session) bool {
|
||
|
|
name := sess.Get("totp_user")
|
||
|
|
if name == "" {
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
started, err := strconv.ParseInt(sess.Get("totp_at"), 10, 64)
|
||
|
|
if err != nil || time.Since(time.Unix(started, 0)) > twofactorWindow {
|
||
|
|
sess.Delete("totp_user")
|
||
|
|
sess.Delete("totp_at")
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
// handleTwofactor answers the second question: a six-digit code from
|
||
|
|
// the account's application, or one of its recovery codes. The same
|
||
|
|
// limiter guards it as the password, so guessing a code costs the same
|
||
|
|
// lockout as guessing a password.
|
||
|
|
func (a *Admin) handleTwofactor(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if err := r.ParseForm(); err != nil {
|
||
|
|
http.Error(w, "bad form", http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
ip := a.clientIP(r)
|
||
|
|
a.deps.LoginLim.Record(ip)
|
||
|
|
if blocked, retryAfter := a.deps.LoginLim.Blocked(ip); blocked {
|
||
|
|
data := a.pageData(r)
|
||
|
|
data.Error = i18n.Admin.N(a.lang(r, nil), "login.seconds", retryAfter)
|
||
|
|
data.RetryAfter = retryAfter
|
||
|
|
a.renderPage(w, r, "twofactor.html", data, http.StatusTooManyRequests)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
sess := session.FromContext(r.Context())
|
||
|
|
if !ValidateCSRF(r, sess) {
|
||
|
|
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if !a.twofactorPending(sess) {
|
||
|
|
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
name := sess.Get("totp_user")
|
||
|
|
code := strings.TrimSpace(r.PostFormValue("code"))
|
||
|
|
ok := false
|
||
|
|
if isTotpShape(code) {
|
||
|
|
ok = a.deps.Users.VerifyTotp(name, code, time.Now())
|
||
|
|
} else {
|
||
|
|
ok = a.deps.Users.ConsumeRecovery(name, code)
|
||
|
|
}
|
||
|
|
if !ok {
|
||
|
|
slog.Warn("admin: second factor refused", "user", name)
|
||
|
|
data := a.pageData(r)
|
||
|
|
data.Error = data.Tr("Wrong or expired code.")
|
||
|
|
a.renderPage(w, r, "twofactor.html", data, http.StatusUnauthorized)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
user := a.deps.Users.Find(name)
|
||
|
|
if user == nil || user.TotpSecret == "" {
|
||
|
|
sess.Delete("totp_user")
|
||
|
|
sess.Delete("totp_at")
|
||
|
|
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
sess.Delete("totp_user")
|
||
|
|
sess.Delete("totp_at")
|
||
|
|
sess.Set("user", user.Username)
|
||
|
|
sess.Set("pv", sessionFingerprint(user.PasswordHash))
|
||
|
|
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||
|
|
}
|
||
|
|
|
||
|
|
// isTotpShape reports whether the answer looks like an application
|
||
|
|
// code; everything else is tried as a recovery code.
|
||
|
|
func isTotpShape(code string) bool {
|
||
|
|
clean := strings.NewReplacer(" ", "", "-", "").Replace(code)
|
||
|
|
return len(clean) == 6 && strings.Trim(clean, "0123456789") == ""
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if err := r.ParseForm(); err != nil {
|
||
|
|
http.Error(w, "bad form", http.StatusBadRequest)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
sess := session.FromContext(r.Context())
|
||
|
|
if !ValidateCSRF(r, sess) {
|
||
|
|
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
// Abandon rather than Clear: Clear would leave the session dirty, and
|
||
|
|
// the middleware's Save would then append a fresh cookie after
|
||
|
|
// Destroy's expiring one, which the browser applies last.
|
||
|
|
sess.Abandon()
|
||
|
|
a.deps.Sessions.Destroy(w)
|
||
|
|
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||
|
|
}
|