package web import ( "context" "errors" "html/template" "log/slog" "net/http" "net/url" "strings" "tug/internal/transport/web/text" ) const LoginPath = "/login" const LoginClaimPath = "/login/claim" func LoginClaimURL(state string) string { return LoginClaimPath + "?" + url.Values{"state": {state}}.Encode() } func IsLoginClaim(r *http.Request) bool { return r.URL.Path == LoginClaimPath } var ( ErrLoginClaim = errors.New("web: the login cookie is set; claim with it") ErrLoginCookie = errors.New("web: the browser did not keep the login cookie") ErrLoginElsewhere = errors.New("web: sign-in belongs to another browser") ErrLoginBusy = errors.New("web: sign-in is busy") ErrLoginExpired = errors.New("web: sign-in request expired") ErrLoginRequired = errors.New("web: sign-in must show the form") ErrLoginStale = errors.New("web: the account changed during sign-in") ) type PasswordLogin interface { Login(ctx context.Context, username, password, state string) (target string, err error) } type LoginEntry struct { Label text.Key Action string } type loginData struct { State string Username Ident Message text.Key Entries []LoginEntry } type login struct { kit *Kit verifier PasswordLogin entries []LoginEntry } func validateEntries(entries []LoginEntry) error { for _, e := range entries { if !relativeSameOrigin(e.Action) { return errors.New("web: a login entry posts to a same-origin path") } if _, err := lookup(e.Label); err != nil { return err } } return nil } func (k *Kit) LoginRoutes(v PasswordLogin, entries ...LoginEntry) []Route { if err := validateEntries(entries); err != nil { panic(err) } l := &login{kit: k, verifier: v, entries: entries} return []Route{ {Method: http.MethodGet, Path: LoginPath, Access: Public, Handler: http.HandlerFunc(l.show)}, {Method: http.MethodGet, Path: LoginClaimPath, Access: Public, Handler: http.HandlerFunc(l.show)}, {Method: http.MethodPost, Path: LoginPath, Access: Public, Handler: http.HandlerFunc(l.submit), csrfFallback: http.HandlerFunc(l.continueSignedIn)}, k.ContinueRoute(), } } func (l *login) render(w http.ResponseWriter, r *http.Request, status int, d loginData, flash template.HTML) { d.Entries = l.entries p := l.kit.formPage(w, r, d) p.Narrow = true if flash != "" { p.Flash = flash } l.kit.render(w, r, status, "login", p) } func (l *login) show(w http.ResponseWriter, r *http.Request) { state := r.URL.Query().Get("state") if state == "" { l.kit.renderError(w, r, http.StatusBadRequest, text.ErrorBadRequestTitle, text.ContinuationIncomplete) return } if s, ok := l.verifier.(SessionLogin); ok { target, err := s.Resume(w, r, state) switch { case err == nil: l.handoff(w, r, target) return case errors.Is(err, ErrLoginExpired): l.kit.renderError(w, r, http.StatusBadRequest, text.ErrorBadRequestTitle, text.LoginExpired) return case errors.Is(err, ErrLoginClaim): w.Header().Set("Cache-Control", "no-store") http.Redirect(w, r, LoginClaimURL(state), http.StatusSeeOther) return case errors.Is(err, ErrLoginCookie): l.kit.renderError(w, r, http.StatusBadRequest, text.ErrorBadRequestTitle, text.LoginCookies) return case errors.Is(err, ErrLoginElsewhere): l.kit.renderError(w, r, http.StatusBadRequest, text.ErrorBadRequestTitle, text.LoginElsewhere) return case errors.Is(err, ErrLoginBusy): w.Header().Set("Retry-After", "1") l.kit.renderError(w, r, http.StatusServiceUnavailable, text.ErrorBusyTitle, text.LoginBusy) return case !errors.Is(err, ErrNoSession) && !errors.Is(err, ErrLoginRequired): slog.Error("session handoff failed", "err", err) } } l.render(w, r, http.StatusOK, loginData{State: state}, "") } func (l *login) handoff(w http.ResponseWriter, r *http.Request, target string) { if !l.kit.continuationTarget(r, target) { slog.Error("session handoff refused: target is not an allowed cross-origin destination") l.kit.renderError(w, r, http.StatusInternalServerError, text.ErrorInternalTitle, text.ErrorInternal) return } w.Header().Set("Cache-Control", "no-store") http.Redirect(w, r, target, http.StatusSeeOther) } func (l *login) submit(w http.ResponseWriter, r *http.Request) { state := r.PostFormValue("state") if state == "" { l.kit.renderError(w, r, http.StatusBadRequest, text.ErrorBadRequestTitle, text.ContinuationIncomplete) return } username, pw := r.PostFormValue("username"), r.PostFormValue("password") shown := loginData{State: echoed(state, echoStateBytes), Username: Ident(echoed(username, echoUsernameBytes))} if username == "" || pw == "" { shown.Message = text.LoginEmpty l.render(w, r, http.StatusOK, shown, "") return } if l.verifier == nil { l.kit.renderError(w, r, http.StatusServiceUnavailable, text.ErrorUnavailableTitle, text.SignInUnavailable) return } var target string var err error s, session := l.verifier.(SessionLogin) if session { target, err = s.Establish(w, r, username, pw, state) } else { target, err = l.verifier.Login(r.Context(), username, pw, state) } switch { case errors.Is(err, ErrLoginElsewhere): l.kit.renderError(w, r, http.StatusBadRequest, text.ErrorBadRequestTitle, text.LoginElsewhere) return case errors.Is(err, ErrLoginExpired): l.kit.renderError(w, r, http.StatusBadRequest, text.ErrorBadRequestTitle, text.LoginExpired) return case errors.Is(err, ErrLoginBusy): shown.Message = text.LoginBusy l.render(w, r, http.StatusServiceUnavailable, shown, "") return case errors.Is(err, ErrLoginStale): shown.Message = text.LoginStale l.render(w, r, http.StatusOK, shown, "") return case err != nil: shown.Message = text.LoginFailed l.render(w, r, http.StatusOK, shown, "") return } if session { l.kit.RotateCSRF(w) } l.kit.Continue(w, r, target) } func (k *Kit) RenderLoginRejected(w http.ResponseWriter, r *http.Request, state string, entries ...LoginEntry) { if err := validateEntries(entries); err != nil { slog.Error("login rejection page refused", "err", err) k.renderError(w, r, http.StatusInternalServerError, text.ErrorInternalTitle, text.ErrorInternal) return } l := &login{kit: k, entries: entries} s, _ := lookup(text.FlashGoogleNotLinked) l.render(w, r, http.StatusOK, loginData{State: state}, template.HTML(template.HTMLEscapeString(s))) } func (l *login) continueSignedIn(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) state := r.PostFormValue("state") p, live := PrincipalFrom(r) if !live || state == "" || strings.ToLower(r.PostFormValue("username")) != string(p.Username) { l.kit.renderError(w, r, http.StatusForbidden, text.ErrorForbiddenTitle, text.ErrorFormExpired) return } w.Header().Set("Cache-Control", "no-store") l.kit.SeeOther(w, r, LoginPath+"?"+url.Values{"state": {state}}.Encode()) }