package web import ( "errors" "log/slog" "net/http" "net/url" "tug/internal/transport/web/text" ) const ( LogoutPath = "/logout" SignedOutPath = "/signed-out" SudoPath = "/sudo" ) var ( ErrReauthRejected = errors.New("web: the password is not correct") ErrReauthBusy = errors.New("web: the password check is busy") ) type SessionLogin interface { PasswordLogin Resume(w http.ResponseWriter, r *http.Request, state string) (target string, err error) Establish(w http.ResponseWriter, r *http.Request, username, password, state string) (target string, err error) } type SessionEnder interface { EndSession(w http.ResponseWriter, r *http.Request, p Principal) error } type Reauthenticator interface { Reauthenticate(w http.ResponseWriter, r *http.Request, p Principal, password string) error } type adminForbiddenData struct { Username Ident Group Ident Account string } type homeData struct { Title text.Key Username Ident } type sudoData struct { Username Ident Next string Message text.Key } func (k *Kit) principal(w http.ResponseWriter, r *http.Request) (Principal, bool) { p, ok := PrincipalFrom(r) if !ok { slog.Error("page refused: the route is not behind a guard") k.renderError(w, r, http.StatusInternalServerError, text.ErrorInternalTitle, text.ErrorInternal) } return p, ok } func (k *Kit) renderAdminForbidden(w http.ResponseWriter, r *http.Request, p Principal) { d := adminForbiddenData{Username: p.Username, Group: Ident(k.server.AdminGroup), Account: k.account + "/"} k.render(w, r, http.StatusForbidden, "admin-forbidden", k.newPage(w, r, d)) } func (k *Kit) RenderSignInFailed(w http.ResponseWriter, r *http.Request) { k.render(w, r, http.StatusBadRequest, "signin-failed", k.newPage(w, r, nil)) } func (k *Kit) SignedOutRoute() Route { return Route{Method: http.MethodGet, Path: SignedOutPath, Access: Public, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { k.render(w, r, http.StatusOK, "signed-out", k.newPage(w, r, nil)) })} } func (k *Kit) homeRoute(title text.Key) Route { return Route{Method: http.MethodGet, Path: "/", Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if p, ok := k.principal(w, r); ok { k.render(w, r, http.StatusOK, "home", k.newPage(w, r, homeData{Title: title, Username: p.Username})) } })} } func (k *Kit) AccountHomeRoute() Route { return k.homeRoute(text.HomeAccountTitle) } func (k *Kit) AdminHomeRoute() Route { return k.homeRoute(text.HomeAdminTitle) } func logoutConfirm(p Principal) Confirm { return Confirm{Title: text.LogoutTitle, Body: text.LogoutBody, Button: text.SessionSignOut, Subject: p.Username, Action: LogoutPath, Cancel: "/", Neutral: true} } type logout struct { kit *Kit ender SessionEnder } func (k *Kit) LogoutRoutes(e SessionEnder) []Route { if e == nil { panic("web: the logout pages need a session ender") } l := &logout{kit: k, ender: e} return []Route{ {Method: http.MethodGet, Path: LogoutPath, Access: SignedIn, Handler: http.HandlerFunc(l.show)}, {Method: http.MethodPost, Path: LogoutPath, Access: SignedIn, Handler: http.HandlerFunc(l.submit)}, } } func (l *logout) show(w http.ResponseWriter, r *http.Request) { p, ok := l.kit.principal(w, r) if !ok { return } l.kit.RenderConfirm(w, r, logoutConfirm(p)) } func (l *logout) submit(w http.ResponseWriter, r *http.Request) { p, ok := l.kit.principal(w, r) if !ok { return } if err := l.ender.EndSession(w, r, p); err != nil && !errors.Is(err, ErrNoSession) { slog.Error("logout failed", "err", err) l.kit.renderError(w, r, http.StatusInternalServerError, text.ErrorInternalTitle, text.ErrorInternal) return } l.kit.RotateCSRF(w) l.kit.SeeOther(w, r, SignedOutPath) } type sudo struct { kit *Kit re Reauthenticator } func (k *Kit) SudoRoutes(re Reauthenticator) []Route { if re == nil { panic("web: the sudo pages need a reauthenticator") } s := &sudo{kit: k, re: re} return []Route{ {Method: http.MethodGet, Path: SudoPath, Handler: http.HandlerFunc(s.show)}, {Method: http.MethodPost, Path: SudoPath, Handler: http.HandlerFunc(s.submit)}, } } func localPath(v string) string { if len(v) > echoNextBytes || !relativeSameOrigin(v) { return "/" } return v } func (s *sudo) render(w http.ResponseWriter, r *http.Request, status int, d sudoData) { s.kit.render(w, r, status, "sudo", s.kit.formPage(w, r, d)) } func (s *sudo) show(w http.ResponseWriter, r *http.Request) { p, ok := s.kit.principal(w, r) if !ok { return } s.render(w, r, http.StatusOK, sudoData{Username: p.Username, Next: localPath(r.URL.Query().Get("next"))}) } func (s *sudo) submit(w http.ResponseWriter, r *http.Request) { p, ok := s.kit.principal(w, r) if !ok { return } d := sudoData{Username: p.Username, Next: localPath(r.PostFormValue("next"))} pw := r.PostFormValue("password") if pw == "" { d.Message = text.SudoEmpty s.render(w, r, http.StatusOK, d) return } err := s.re.Reauthenticate(w, r, p, pw) switch { case err == nil: s.kit.SeeOther(w, r, d.Next) case errors.Is(err, ErrNoSession): s.kit.SeeOther(w, r, "/") case errors.Is(err, ErrReauthBusy): d.Message = text.SudoBusy s.render(w, r, http.StatusServiceUnavailable, d) case errors.Is(err, ErrReauthRejected): d.Message = text.SudoFailed s.render(w, r, http.StatusOK, d) default: slog.Error("password re-entry failed", "err", err) s.kit.renderError(w, r, http.StatusInternalServerError, text.ErrorInternalTitle, text.ErrorInternal) } } func (k *Kit) RequireSudo(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p, ok := k.principal(w, r) switch { case !ok: case p.SudoUntil.After(k.now()): h.ServeHTTP(w, r) case r.Method == http.MethodGet && len(r.URL.RequestURI()) > echoNextBytes: k.SeeOther(w, r, SudoPath) case r.Method == http.MethodGet: k.SeeOther(w, r, SudoPath+"?"+url.Values{"next": {r.URL.RequestURI()}}.Encode()) default: if err := k.SetFlash(w, text.FlashSudoNothingChanged, ""); err != nil { slog.Error("sudo flash not set", "err", err) } k.SeeOther(w, r, r.URL.EscapedPath()) } }) }