1package web
2
3import (
4 "context"
5 "errors"
6 "html/template"
7 "log/slog"
8 "net/http"
9 "net/url"
10 "strings"
11
12 "tug/internal/transport/web/text"
13)
14
15const LoginPath = "/login"
16
17const LoginClaimPath = "/login/claim"
18
19func LoginClaimURL(state string) string {
20 return LoginClaimPath + "?" + url.Values{"state": {state}}.Encode()
21}
22
23func IsLoginClaim(r *http.Request) bool {
24 return r.URL.Path == LoginClaimPath
25}
26
27var (
28 ErrLoginClaim = errors.New("web: the login cookie is set; claim with it")
29 ErrLoginCookie = errors.New("web: the browser did not keep the login cookie")
30 ErrLoginElsewhere = errors.New("web: sign-in belongs to another browser")
31 ErrLoginBusy = errors.New("web: sign-in is busy")
32 ErrLoginExpired = errors.New("web: sign-in request expired")
33 ErrLoginRequired = errors.New("web: sign-in must show the form")
34 ErrLoginStale = errors.New("web: the account changed during sign-in")
35)
36
37type PasswordLogin interface {
38 Login(ctx context.Context, username, password, state string) (target string, err error)
39}
40
41type LoginEntry struct {
42 Label text.Key
43 Action string
44}
45
46type loginData struct {
47 State string
48 Username Ident
49 Message text.Key
50 Entries []LoginEntry
51}
52
53type login struct {
54 kit *Kit
55 verifier PasswordLogin
56 entries []LoginEntry
57}
58
59func validateEntries(entries []LoginEntry) error {
60 for _, e := range entries {
61 if !relativeSameOrigin(e.Action) {
62 return errors.New("web: a login entry posts to a same-origin path")
63 }
64 if _, err := lookup(e.Label); err != nil {
65 return err
66 }
67 }
68 return nil
69}
70
71func (k *Kit) LoginRoutes(v PasswordLogin, entries ...LoginEntry) []Route {
72 if err := validateEntries(entries); err != nil {
73 panic(err)
74 }
75 l := &login{kit: k, verifier: v, entries: entries}
76 return []Route{
77 {Method: http.MethodGet, Path: LoginPath, Access: Public, Handler: http.HandlerFunc(l.show)},
78 {Method: http.MethodGet, Path: LoginClaimPath, Access: Public, Handler: http.HandlerFunc(l.show)},
79 {Method: http.MethodPost, Path: LoginPath, Access: Public, Handler: http.HandlerFunc(l.submit), csrfFallback: http.HandlerFunc(l.continueSignedIn)},
80 k.ContinueRoute(),
81 }
82}
83
84func (l *login) render(w http.ResponseWriter, r *http.Request, status int, d loginData, flash template.HTML) {
85 d.Entries = l.entries
86 p := l.kit.formPage(w, r, d)
87 p.Narrow = true
88 if flash != "" {
89 p.Flash = flash
90 }
91 l.kit.render(w, r, status, "login", p)
92}
93
94func (l *login) show(w http.ResponseWriter, r *http.Request) {
95 state := r.URL.Query().Get("state")
96 if state == "" {
97 l.kit.renderError(w, r, http.StatusBadRequest, text.ErrorBadRequestTitle, text.ContinuationIncomplete)
98 return
99 }
100 if s, ok := l.verifier.(SessionLogin); ok {
101 target, err := s.Resume(w, r, state)
102 switch {
103 case err == nil:
104 l.handoff(w, r, target)
105 return
106 case errors.Is(err, ErrLoginExpired):
107 l.kit.renderError(w, r, http.StatusBadRequest, text.ErrorBadRequestTitle, text.LoginExpired)
108 return
109 case errors.Is(err, ErrLoginClaim):
110 w.Header().Set("Cache-Control", "no-store")
111 http.Redirect(w, r, LoginClaimURL(state), http.StatusSeeOther)
112 return
113 case errors.Is(err, ErrLoginCookie):
114 l.kit.renderError(w, r, http.StatusBadRequest, text.ErrorBadRequestTitle, text.LoginCookies)
115 return
116 case errors.Is(err, ErrLoginElsewhere):
117 l.kit.renderError(w, r, http.StatusBadRequest, text.ErrorBadRequestTitle, text.LoginElsewhere)
118 return
119 case errors.Is(err, ErrLoginBusy):
120 w.Header().Set("Retry-After", "1")
121 l.kit.renderError(w, r, http.StatusServiceUnavailable, text.ErrorBusyTitle, text.LoginBusy)
122 return
123 case !errors.Is(err, ErrNoSession) && !errors.Is(err, ErrLoginRequired):
124 slog.Error("session handoff failed", "err", err)
125 }
126 }
127 l.render(w, r, http.StatusOK, loginData{State: state}, "")
128}
129
130func (l *login) handoff(w http.ResponseWriter, r *http.Request, target string) {
131 if !l.kit.continuationTarget(r, target) {
132 slog.Error("session handoff refused: target is not an allowed cross-origin destination")
133 l.kit.renderError(w, r, http.StatusInternalServerError, text.ErrorInternalTitle, text.ErrorInternal)
134 return
135 }
136 w.Header().Set("Cache-Control", "no-store")
137 http.Redirect(w, r, target, http.StatusSeeOther)
138}
139
140func (l *login) submit(w http.ResponseWriter, r *http.Request) {
141 state := r.PostFormValue("state")
142 if state == "" {
143 l.kit.renderError(w, r, http.StatusBadRequest, text.ErrorBadRequestTitle, text.ContinuationIncomplete)
144 return
145 }
146 username, pw := r.PostFormValue("username"), r.PostFormValue("password")
147 shown := loginData{State: echoed(state, echoStateBytes), Username: Ident(echoed(username, echoUsernameBytes))}
148 if username == "" || pw == "" {
149 shown.Message = text.LoginEmpty
150 l.render(w, r, http.StatusOK, shown, "")
151 return
152 }
153 if l.verifier == nil {
154 l.kit.renderError(w, r, http.StatusServiceUnavailable, text.ErrorUnavailableTitle, text.SignInUnavailable)
155 return
156 }
157 var target string
158 var err error
159 s, session := l.verifier.(SessionLogin)
160 if session {
161 target, err = s.Establish(w, r, username, pw, state)
162 } else {
163 target, err = l.verifier.Login(r.Context(), username, pw, state)
164 }
165 switch {
166 case errors.Is(err, ErrLoginElsewhere):
167 l.kit.renderError(w, r, http.StatusBadRequest, text.ErrorBadRequestTitle, text.LoginElsewhere)
168 return
169 case errors.Is(err, ErrLoginExpired):
170 l.kit.renderError(w, r, http.StatusBadRequest, text.ErrorBadRequestTitle, text.LoginExpired)
171 return
172 case errors.Is(err, ErrLoginBusy):
173 shown.Message = text.LoginBusy
174 l.render(w, r, http.StatusServiceUnavailable, shown, "")
175 return
176 case errors.Is(err, ErrLoginStale):
177 shown.Message = text.LoginStale
178 l.render(w, r, http.StatusOK, shown, "")
179 return
180 case err != nil:
181 shown.Message = text.LoginFailed
182 l.render(w, r, http.StatusOK, shown, "")
183 return
184 }
185 if session {
186 l.kit.RotateCSRF(w)
187 }
188 l.kit.Continue(w, r, target)
189}
190
191func (k *Kit) RenderLoginRejected(w http.ResponseWriter, r *http.Request, state string, entries ...LoginEntry) {
192 if err := validateEntries(entries); err != nil {
193 slog.Error("login rejection page refused", "err", err)
194 k.renderError(w, r, http.StatusInternalServerError, text.ErrorInternalTitle, text.ErrorInternal)
195 return
196 }
197 l := &login{kit: k, entries: entries}
198 s, _ := lookup(text.FlashGoogleNotLinked)
199 l.render(w, r, http.StatusOK, loginData{State: state}, template.HTML(template.HTMLEscapeString(s)))
200}
201
202func (l *login) continueSignedIn(w http.ResponseWriter, r *http.Request) {
203 r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
204 state := r.PostFormValue("state")
205 p, live := PrincipalFrom(r)
206 if !live || state == "" || strings.ToLower(r.PostFormValue("username")) != string(p.Username) {
207 l.kit.renderError(w, r, http.StatusForbidden, text.ErrorForbiddenTitle, text.ErrorFormExpired)
208 return
209 }
210 w.Header().Set("Cache-Control", "no-store")
211 l.kit.SeeOther(w, r, LoginPath+"?"+url.Values{"state": {state}}.Encode())
212}