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}