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())
}