Raw
  1package web
  2
  3import (
  4	"errors"
  5	"log/slog"
  6	"net/http"
  7	"net/url"
  8
  9	"tug/internal/transport/web/text"
 10)
 11
 12const (
 13	LogoutPath    = "/logout"
 14	SignedOutPath = "/signed-out"
 15	SudoPath      = "/sudo"
 16)
 17
 18var (
 19	ErrReauthRejected = errors.New("web: the password is not correct")
 20	ErrReauthBusy     = errors.New("web: the password check is busy")
 21)
 22
 23type SessionLogin interface {
 24	PasswordLogin
 25	Resume(w http.ResponseWriter, r *http.Request, state string) (target string, err error)
 26	Establish(w http.ResponseWriter, r *http.Request, username, password, state string) (target string, err error)
 27}
 28
 29type SessionEnder interface {
 30	EndSession(w http.ResponseWriter, r *http.Request, p Principal) error
 31}
 32
 33type Reauthenticator interface {
 34	Reauthenticate(w http.ResponseWriter, r *http.Request, p Principal, password string) error
 35}
 36
 37type adminForbiddenData struct {
 38	Username Ident
 39	Group    Ident
 40	Account  string
 41}
 42
 43type homeData struct {
 44	Title    text.Key
 45	Username Ident
 46}
 47
 48type sudoData struct {
 49	Username Ident
 50	Next     string
 51	Message  text.Key
 52}
 53
 54func (k *Kit) principal(w http.ResponseWriter, r *http.Request) (Principal, bool) {
 55	p, ok := PrincipalFrom(r)
 56	if !ok {
 57		slog.Error("page refused: the route is not behind a guard")
 58		k.renderError(w, r, http.StatusInternalServerError, text.ErrorInternalTitle, text.ErrorInternal)
 59	}
 60	return p, ok
 61}
 62
 63func (k *Kit) renderAdminForbidden(w http.ResponseWriter, r *http.Request, p Principal) {
 64	d := adminForbiddenData{Username: p.Username, Group: Ident(k.server.AdminGroup), Account: k.account + "/"}
 65	k.render(w, r, http.StatusForbidden, "admin-forbidden", k.newPage(w, r, d))
 66}
 67
 68func (k *Kit) RenderSignInFailed(w http.ResponseWriter, r *http.Request) {
 69	k.render(w, r, http.StatusBadRequest, "signin-failed", k.newPage(w, r, nil))
 70}
 71
 72func (k *Kit) SignedOutRoute() Route {
 73	return Route{Method: http.MethodGet, Path: SignedOutPath, Access: Public, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 74		k.render(w, r, http.StatusOK, "signed-out", k.newPage(w, r, nil))
 75	})}
 76}
 77
 78func (k *Kit) homeRoute(title text.Key) Route {
 79	return Route{Method: http.MethodGet, Path: "/", Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 80		if p, ok := k.principal(w, r); ok {
 81			k.render(w, r, http.StatusOK, "home", k.newPage(w, r, homeData{Title: title, Username: p.Username}))
 82		}
 83	})}
 84}
 85
 86func (k *Kit) AccountHomeRoute() Route {
 87	return k.homeRoute(text.HomeAccountTitle)
 88}
 89
 90func (k *Kit) AdminHomeRoute() Route {
 91	return k.homeRoute(text.HomeAdminTitle)
 92}
 93
 94func logoutConfirm(p Principal) Confirm {
 95	return Confirm{Title: text.LogoutTitle, Body: text.LogoutBody, Button: text.SessionSignOut, Subject: p.Username, Action: LogoutPath, Cancel: "/", Neutral: true}
 96}
 97
 98type logout struct {
 99	kit   *Kit
100	ender SessionEnder
101}
102
103func (k *Kit) LogoutRoutes(e SessionEnder) []Route {
104	if e == nil {
105		panic("web: the logout pages need a session ender")
106	}
107	l := &logout{kit: k, ender: e}
108	return []Route{
109		{Method: http.MethodGet, Path: LogoutPath, Access: SignedIn, Handler: http.HandlerFunc(l.show)},
110		{Method: http.MethodPost, Path: LogoutPath, Access: SignedIn, Handler: http.HandlerFunc(l.submit)},
111	}
112}
113
114func (l *logout) show(w http.ResponseWriter, r *http.Request) {
115	p, ok := l.kit.principal(w, r)
116	if !ok {
117		return
118	}
119	l.kit.RenderConfirm(w, r, logoutConfirm(p))
120}
121
122func (l *logout) submit(w http.ResponseWriter, r *http.Request) {
123	p, ok := l.kit.principal(w, r)
124	if !ok {
125		return
126	}
127	if err := l.ender.EndSession(w, r, p); err != nil && !errors.Is(err, ErrNoSession) {
128		slog.Error("logout failed", "err", err)
129		l.kit.renderError(w, r, http.StatusInternalServerError, text.ErrorInternalTitle, text.ErrorInternal)
130		return
131	}
132	l.kit.RotateCSRF(w)
133	l.kit.SeeOther(w, r, SignedOutPath)
134}
135
136type sudo struct {
137	kit *Kit
138	re  Reauthenticator
139}
140
141func (k *Kit) SudoRoutes(re Reauthenticator) []Route {
142	if re == nil {
143		panic("web: the sudo pages need a reauthenticator")
144	}
145	s := &sudo{kit: k, re: re}
146	return []Route{
147		{Method: http.MethodGet, Path: SudoPath, Handler: http.HandlerFunc(s.show)},
148		{Method: http.MethodPost, Path: SudoPath, Handler: http.HandlerFunc(s.submit)},
149	}
150}
151
152func localPath(v string) string {
153	if len(v) > echoNextBytes || !relativeSameOrigin(v) {
154		return "/"
155	}
156	return v
157}
158
159func (s *sudo) render(w http.ResponseWriter, r *http.Request, status int, d sudoData) {
160	s.kit.render(w, r, status, "sudo", s.kit.formPage(w, r, d))
161}
162
163func (s *sudo) show(w http.ResponseWriter, r *http.Request) {
164	p, ok := s.kit.principal(w, r)
165	if !ok {
166		return
167	}
168	s.render(w, r, http.StatusOK, sudoData{Username: p.Username, Next: localPath(r.URL.Query().Get("next"))})
169}
170
171func (s *sudo) submit(w http.ResponseWriter, r *http.Request) {
172	p, ok := s.kit.principal(w, r)
173	if !ok {
174		return
175	}
176	d := sudoData{Username: p.Username, Next: localPath(r.PostFormValue("next"))}
177	pw := r.PostFormValue("password")
178	if pw == "" {
179		d.Message = text.SudoEmpty
180		s.render(w, r, http.StatusOK, d)
181		return
182	}
183	err := s.re.Reauthenticate(w, r, p, pw)
184	switch {
185	case err == nil:
186		s.kit.SeeOther(w, r, d.Next)
187	case errors.Is(err, ErrNoSession):
188		s.kit.SeeOther(w, r, "/")
189	case errors.Is(err, ErrReauthBusy):
190		d.Message = text.SudoBusy
191		s.render(w, r, http.StatusServiceUnavailable, d)
192	case errors.Is(err, ErrReauthRejected):
193		d.Message = text.SudoFailed
194		s.render(w, r, http.StatusOK, d)
195	default:
196		slog.Error("password re-entry failed", "err", err)
197		s.kit.renderError(w, r, http.StatusInternalServerError, text.ErrorInternalTitle, text.ErrorInternal)
198	}
199}
200
201func (k *Kit) RequireSudo(h http.Handler) http.Handler {
202	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
203		p, ok := k.principal(w, r)
204		switch {
205		case !ok:
206		case p.SudoUntil.After(k.now()):
207			h.ServeHTTP(w, r)
208		case r.Method == http.MethodGet && len(r.URL.RequestURI()) > echoNextBytes:
209			k.SeeOther(w, r, SudoPath)
210		case r.Method == http.MethodGet:
211			k.SeeOther(w, r, SudoPath+"?"+url.Values{"next": {r.URL.RequestURI()}}.Encode())
212		default:
213			if err := k.SetFlash(w, text.FlashSudoNothingChanged, ""); err != nil {
214				slog.Error("sudo flash not set", "err", err)
215			}
216			k.SeeOther(w, r, r.URL.EscapedPath())
217		}
218	})
219}