package web import ( "crypto/hmac" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" "encoding/json" "errors" "log" "net/http" "regexp" "strings" "time" "git.veillax.com/Veillax/basking/internal/config" "git.veillax.com/Veillax/basking/internal/oidc" ) const ( sessionCookie = "basking_session" flowCookie = "basking_signin" sessionTTL = 14 * 24 * time.Hour flowTTL = 10 * time.Minute ) // A cookie is "payload.signature": the payload is JSON, the signature is an HMAC with the secret from // the settings file. Nothing is kept on the server, so a restart does not sign anyone out. func seal(c config.Config, kind string, v any) string { raw, _ := json.Marshal(v) payload := base64.RawURLEncoding.EncodeToString(raw) return payload + "." + mac(c, kind, payload) } func mac(c config.Config, kind, payload string) string { m := hmac.New(sha256.New, []byte(c.Secret)) m.Write([]byte(kind + "|" + payload)) return hex.EncodeToString(m.Sum(nil)) } func unseal(c config.Config, kind, value string, v any) bool { payload, sig, ok := strings.Cut(value, ".") if !ok || !hmac.Equal([]byte(sig), []byte(mac(c, kind, payload))) { return false } raw, err := base64.RawURLEncoding.DecodeString(payload) return err == nil && json.Unmarshal(raw, v) == nil } type sessionData struct { Sub string `json:"s"` Name string `json:"n"` Email string `json:"e"` User string `json:"u"` Exp int64 `json:"x"` } type flowData struct { State string `json:"s"` Nonce string `json:"n"` Verifier string `json:"v"` Next string `json:"t"` Exp int64 `json:"x"` } // session reads the sign-in cookie. The allowed list is checked on every request, so taking someone // off it takes effect at once. func (s *Server) session(c config.Config, r *http.Request) (sessionData, bool) { var d sessionData ck, err := r.Cookie(sessionCookie) if err != nil || !unseal(c, "session", ck.Value, &d) || d.Sub == "" || time.Now().Unix() > d.Exp { return sessionData{}, false } return d, allowed(c, d) } func allowed(c config.Config, d sessionData) bool { if len(c.Allowed) == 0 { return true } for _, a := range c.Allowed { for _, who := range []string{d.Email, d.User, d.Sub} { if who != "" && strings.EqualFold(strings.TrimSpace(a), who) { return true } } } return false } func setCookie(w http.ResponseWriter, c config.Config, name, value, path string, ttl time.Duration) { ck := &http.Cookie{Name: name, Value: value, Path: path, HttpOnly: true, Secure: c.Secure(), SameSite: http.SameSiteLaxMode} if value == "" { ck.MaxAge = -1 } else { ck.MaxAge = int(ttl.Seconds()) } http.SetCookie(w, ck) } // Where to go after signing in: only a place inside Basking, such as #/p/abc. var nextRe = regexp.MustCompile(`^#/[A-Za-z0-9/_.~%-]{0,300}$`) func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) { c := s.cfg.Get() next := r.URL.Query().Get("next") if !nextRe.MatchString(next) { next = "" } f := flowData{State: oidc.Random(18), Nonce: oidc.Random(18), Verifier: oidc.Random(40), Next: next, Exp: time.Now().Add(flowTTL).Unix()} to, err := s.oidc.AuthURL(r.Context(), f.State, f.Nonce, oidc.Challenge(f.Verifier)) if err != nil { log.Printf("sign-in: %v", err) http.Redirect(w, r, "/?signin=unavailable", http.StatusSeeOther) return } setCookie(w, c, flowCookie, seal(c, "flow", f), "/auth/", flowTTL) http.Redirect(w, r, to, http.StatusSeeOther) } func (s *Server) authCallback(w http.ResponseWriter, r *http.Request) { c := s.cfg.Get() back := func(why string) { http.Redirect(w, r, "/?signin="+why, http.StatusSeeOther) } var f flowData ck, err := r.Cookie(flowCookie) setCookie(w, c, flowCookie, "", "/auth/", 0) // a sign-in attempt is used once if err != nil || !unseal(c, "flow", ck.Value, &f) || time.Now().Unix() > f.Exp { back("expired") return } q := r.URL.Query() if e := q.Get("error"); e != "" { log.Printf("sign-in: the provider answered %q: %s", e, q.Get("error_description")) back("denied") return } if subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(f.State)) != 1 || q.Get("code") == "" { back("failed") return } id, err := s.oidc.Exchange(r.Context(), q.Get("code"), f.Verifier, f.Nonce) if err != nil { log.Printf("sign-in: %v", err) if errors.Is(err, oidc.ErrUnavailable) { back("unavailable") } else { back("failed") } return } d := sessionData{Sub: id.Sub, Name: firstOf(id.Name, id.Username, id.Email, "You"), Email: id.Email, User: id.Username, Exp: time.Now().Add(sessionTTL).Unix()} if !allowed(c, d) { log.Printf("sign-in: %s (%s) is not on the allowed list", d.Name, firstOf(d.Email, d.User, d.Sub)) back("denied") return } setCookie(w, c, sessionCookie, seal(c, "session", d), "/", sessionTTL) http.Redirect(w, r, "/"+f.Next, http.StatusSeeOther) } func firstOf(s ...string) string { for _, x := range s { if strings.TrimSpace(x) != "" { return x } } return "" } func (s *Server) logout(w http.ResponseWriter, r *http.Request, p principal) { setCookie(w, s.cfg.Get(), sessionCookie, "", "/", 0) writeJSON(w, map[string]bool{"ok": true}) } func (s *Server) me(w http.ResponseWriter, r *http.Request, p principal) { writeJSON(w, map[string]any{"name": p.Name, "email": p.Email, "version": s.version}) }