| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/rand" |
| 6 | "crypto/sha256" |
| 7 | "database/sql" |
| 8 | "encoding/hex" |
| 9 | "errors" |
| 10 | "net/http" |
| 11 | "net/url" |
| 12 | "strings" |
| 13 | "time" |
| 14 | ) |
| 15 | |
| 16 | const sessionCookie = "tiny_session" |
| 17 | |
| 18 | // requestURL binds signatures to the original external request, including a |
| 19 | // tenant path stripped by the router. Forwarded headers are never trusted. |
| 20 | func (t *Tenant) requestURL(r *http.Request) string { |
| 21 | scheme := "http" |
| 22 | if r.TLS != nil { |
| 23 | scheme = "https" |
| 24 | } else if base, err := url.Parse(t.app.cfg.PublicURL); err == nil && base.Scheme == "https" { |
| 25 | scheme = "https" |
| 26 | } |
| 27 | requestURI := r.RequestURI |
| 28 | if requestURI == "" { |
| 29 | requestURI = r.URL.RequestURI() |
| 30 | } |
| 31 | if strings.HasPrefix(requestURI, "http://" ) || strings.HasPrefix(requestURI, "https://" ) { |
| 32 | if u, err := url.Parse(requestURI); err == nil { |
| 33 | requestURI = u.RequestURI() |
| 34 | } |
| 35 | } |
| 36 | return scheme + "://" + r.Host + requestURI |
| 37 | } |
| 38 | |
| 39 | func (t *Tenant) initSessions(ctx context.Context) error { |
| 40 | _, err := t.store.DB().ExecContext(ctx, `CREATE TABLE IF NOT EXISTS browser_sessions(token_hash TEXT PRIMARY KEY,pubkey TEXT NOT NULL,expires_at INTEGER NOT NULL)` ) |
| 41 | return err |
| 42 | } |
| 43 | |
| 44 | func sessionHash(token string) string { |
| 45 | sum := sha256.Sum256([]byte(token)) |
| 46 | return hex.EncodeToString(sum[:]) |
| 47 | } |
| 48 | |
| 49 | func (t *Tenant) cookieActor(r *http.Request) (string, error) { |
| 50 | cookie, err := r.Cookie(sessionCookie) |
| 51 | if errors.Is(err, http.ErrNoCookie) { |
| 52 | return "" , nil |
| 53 | } |
| 54 | if err != nil { |
| 55 | return "" , err |
| 56 | } |
| 57 | var actor string |
| 58 | err = t.store.DB().QueryRowContext(r.Context(), "SELECT pubkey FROM browser_sessions WHERE token_hash=? AND expires_at>?" , sessionHash(cookie.Value), time.Now().Unix()).Scan(&actor) |
| 59 | if errors.Is(err, sql.ErrNoRows) { |
| 60 | return "" , nil |
| 61 | } |
| 62 | if err != nil { |
| 63 | return "" , err |
| 64 | } |
| 65 | banned, err := t.community.IsBanned(r.Context(), actor) |
| 66 | if banned { |
| 67 | return "" , errors.New("blocked: this pubkey is banned" ) |
| 68 | } |
| 69 | return actor, err |
| 70 | } |
| 71 | |
| 72 | func (t *Tenant) sessionHTTP(w http.ResponseWriter, r *http.Request) { |
| 73 | if r.Method != http.MethodPost { |
| 74 | w.Header().Set("Allow" , "POST" ) |
| 75 | w.WriteHeader(http.StatusMethodNotAllowed) |
| 76 | return |
| 77 | } |
| 78 | base, err := url.Parse(t.requestURL(r)) |
| 79 | if err != nil { |
| 80 | http.Error(w, "invalid relay URL" , 500) |
| 81 | return |
| 82 | } |
| 83 | // Cookie-only requests may change session state only from this origin. |
| 84 | if origin := r.Header.Get("Origin" ); origin != "" && origin != base.Scheme+"://" +base.Host { |
| 85 | http.Error(w, "foreign origin" , http.StatusForbidden) |
| 86 | return |
| 87 | } |
| 88 | cookiePath := strings.TrimSuffix(base.Path, r.URL.Path) + "/" |
| 89 | cookie := &http.Cookie{Name: sessionCookie, Path: cookiePath, HttpOnly: true, Secure: base.Scheme == "https" , SameSite: http.SameSiteStrictMode} |
| 90 | if r.URL.Path == "/session/logout" { |
| 91 | if r.Header.Get("Origin" ) == "" && r.Header.Get("Authorization" ) == "" { |
| 92 | http.Error(w, "origin or signature required" , http.StatusForbidden) |
| 93 | return |
| 94 | } |
| 95 | if r.Header.Get("Origin" ) == "" { |
| 96 | if _, err := t.resolveUIActor(r); err != nil { |
| 97 | http.Error(w, "invalid logout signature" , http.StatusUnauthorized) |
| 98 | return |
| 99 | } |
| 100 | } |
| 101 | if prior, err := r.Cookie(sessionCookie); err == nil { |
| 102 | if _, err := t.store.DB().ExecContext(r.Context(), "DELETE FROM browser_sessions WHERE token_hash=?" , sessionHash(prior.Value)); err != nil { |
| 103 | http.Error(w, "logout failed" , 500) |
| 104 | return |
| 105 | } |
| 106 | } |
| 107 | cookie.MaxAge = -1 |
| 108 | http.SetCookie(w, cookie) |
| 109 | writeJSON(w, 200, map[string]any{"ok" : true}) |
| 110 | return |
| 111 | } |
| 112 | body, err := t.requestBody(w, r) |
| 113 | if err != nil { |
| 114 | http.Error(w, err.Error(), 400) |
| 115 | return |
| 116 | } |
| 117 | session, err := t.session(r, body) |
| 118 | if err != nil { |
| 119 | http.Error(w, err.Error(), 401) |
| 120 | return |
| 121 | } |
| 122 | actor := first(session.PubKeys) |
| 123 | banned, err := t.community.IsBanned(r.Context(), actor) |
| 124 | if err != nil { |
| 125 | http.Error(w, "login failed" , 500) |
| 126 | return |
| 127 | } |
| 128 | if banned { |
| 129 | http.Error(w, "blocked: this pubkey is banned" , 403) |
| 130 | return |
| 131 | } |
| 132 | var token [32]byte |
| 133 | if _, err := rand.Read(token[:]); err != nil { |
| 134 | http.Error(w, "login failed" , 500) |
| 135 | return |
| 136 | } |
| 137 | cookie.Value = hex.EncodeToString(token[:]) |
| 138 | cookie.MaxAge = int((24 * time.Hour) / time.Second) |
| 139 | cookie.Expires = time.Now().Add(24 * time.Hour) |
| 140 | if _, err := t.store.DB().ExecContext(r.Context(), "INSERT INTO browser_sessions(token_hash,pubkey,expires_at) VALUES(?,?,?)" , sessionHash(cookie.Value), actor, cookie.Expires.Unix()); err != nil { |
| 141 | http.Error(w, "login failed" , 500) |
| 142 | return |
| 143 | } |
| 144 | http.SetCookie(w, cookie) |
| 145 | if actor == t.Policy().Owner { |
| 146 | if err := t.records.Heartbeat(r.Context(), actor, time.Now().Unix()); err != nil { |
| 147 | t.app.telemetry.Logger().Error("owner heartbeat" , "error" , err) |
| 148 | } |
| 149 | } |
| 150 | writeJSON(w, 200, map[string]any{"pubkey" : actor}) |
| 151 | } |