Raw
  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}