mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 02:16:45 +08:00
* feat(auth): slide UI session expiry instead of fixing it at login A session token's exp was stamped once at login and never moved, so every continuously active user was logged out 30 days later, always mid-task. The session now re-issues itself once it drops below half its TTL, turning that scheduled logout into an idle bound. Renewal is stateless: it copies the claims of a token we signed and moves exp, so there is no session table and no DB read on the hot path. - Browsers are renewed inline by middleware.Auth on safe requests, keeping the session HttpOnly — no client change and no multi-tab coordination. - Clients holding the session as a string (Desktop, mobile) get POST /api/auth/refresh, which only ever accepts a UI JWT. PATs, agent task tokens and cloud-node PATs are refused: a machine credential must not become an interactive session. An expired JWT cannot be revived. - CSRF tokens are now bound to a new sid claim, stable across renewals, so re-issuing the auth cookie no longer invalidates a CSRF token another tab already read. The pre-existing binding stays valid so sessions that predate this keep working until they renew. - CloudFront cookies are re-signed alongside a renewal, so the CDN policy cannot outlive its session. The idle bound is one-sided and the comments say so: a session last used while it still had over half its TTL left is not extended, so the guaranteed survivable gap is TTL/2, not TTL. Refs MUL-7436 Co-authored-by: multica-agent <[email protected]> * feat(auth): renew the session on Desktop, mobile, and legacy token-mode web Completes the client half of sliding sessions. Browsers on cookie auth need nothing — the server re-issues their cookie inline — so this covers the clients that carry the session as a string and have to persist a new one. Renewal follows use, not a clock. Checks run at launch, on returning to the foreground, and on interaction; there is deliberately no interval timer, because a timer would keep an abandoned but still-open window (or a backgrounded phone) signed in forever. The server decides whether a session is renewable and tells the client when to ask again, derived from the deployment's configured TTL — so no client reads exp, hardcodes a cadence, or has to reason about clock skew. Renewal is not a session event, and each step protects that: - Concurrent triggers collapse into one request, so two valid tokens never race to be the one stored. On mobile the single-flight flag is set before the first await, since reading the Keychain is itself async. - A response that lands after logout or an account switch is discarded by comparing against the session the attempt started from; writing it back would resurrect a session the app has already torn down. - Offline, 5xx and an unreadable body all keep the current session, which is still valid for at least another half TTL. Only a real 401 ends a session, and that path is untouched — nothing here can clear tabs or drafts. - WebSocket clients now read the token at connect time instead of pinning the one captured when they were built, so a reconnect after a renewal authenticates with the current credential. Web also retries a rejected CSRF token once. Session-bound CSRF makes that unreachable in the steady state; it covers the single transition where a session predating that binding is renewed for the first time. Refs MUL-7436 Co-authored-by: multica-agent <[email protected]> * fix(auth): address review blockers on sliding session renewal Six fixes from review, each with a regression test that fails without it. An expired session was answered 403, not 401. The CSRF gate runs before the JWT is validated, and it read `sid` through the expiry-enforcing parser — so an expired cookie lost its sid, its perfectly good CSRF token failed to verify, and the user's first action after expiry was a rejected write instead of a return to the login page. Reading the session id and deciding access are now different questions with different parsers: the first only needs the signature, the second still enforces expiry. Rolling back was not survivable. A previous release can verify only the token-bound CSRF binding, so once a session was issued or renewed by this one, a rollback left every signed-in user able to read and unable to write, with no client retry able to help. Both bindings are now issued: clients echo both, the server accepts either, and the token-bound cookie is re-minted on every renewal so a rollback taken at any moment still finds a usable pair. Mobile could resurrect a session it had just ended. Keychain deletes are async, so a renewal that sampled storage after logout began but before the delete landed would write its replacement back. A session epoch, incremented synchronously at the start of logout, a 401 teardown and a new sign-in, closes the window comparing tokens cannot — including the case where logout lands during the renewal's own write, which is now undone rather than left behind. Mobile only checked at launch and on foreground, so an app held in the foreground and used continuously would never check again and could expire under the user's hands. Every touch now passes through a capture-phase responder that declines the gesture it observes. CloudFront cookies were re-signed by a middleware not every renewing route mounts — the plugin bridge renews without it, and would have slid the session forward while leaving the CDN policy behind. Re-signing moved next to the renewal itself, so the two cannot diverge by route. The renewal check interval could exceed the window it has to land in: a flat five-minute floor meant any TTL under ten minutes produced a cadence longer than the renewal window, logging out clients that followed it exactly. The cadence is now capped at half the threshold, which outranks both clamps. Refs MUL-7436 Co-authored-by: multica-agent <[email protected]> * fix(auth): close the CORS, credential-write and short-TTL gaps Three blockers from the second review round. The two CSRF values now travel in one header instead of two. A second header name would have to be in the server's CORS allowlist, and that list is version-specific: a browser holding a cookie this release issued, talking to a rolled-back server, would send a header that server never allowlisted. The preflight fails, the request never reaches a handler, and no retry helps — precisely the half-logged-in state the second cookie exists to prevent. With one header name the server just tries both bindings, and the client switches to the token-bound value when a server rejects the session-bound one. It switches back as soon as the cookie changes, keyed on the rejected value rather than a flag, so it cannot oscillate once per renewal. A test now pins the CSRF header to the allowlist so a future second header fails here rather than in production. Mobile credential writes are serialized, and a stale renewal is refused rather than undone. Compensating after the fact meant deleting whatever was stored — which, after a logout and a fresh sign-in, is the new account's token: the app would be logged in in memory and signed out on the next launch. The check and the write now happen inside one serialized unit, so a logout or a sign-in cannot land between them and a refusal writes nothing at all. AUTH_TOKEN_TTL has a documented one-minute minimum. Clients are told when to check again in whole seconds, so a shorter TTL asks the wire to carry a sub-second cadence: it truncates, and below about four seconds it serialises to 0. Values under the minimum are clamped up with a startup warning rather than honoured. Mobile's floors were never actually lowered in the previous commit — 1h/1m, not the 5s/5m claimed — so both clients now floor at 5s, below anything the server can send, and fall back to 30s, which fits the smallest supported window. The cadence tests assert the serialised integer a client actually receives, not the internal time.Duration. Refs MUL-7436 Co-authored-by: multica-agent <[email protected]> * fix(auth): keep a failed renewal cheap and a renewed token shared Two blockers from the full-PR review, plus the documentation the config change owes. A failed check no longer consumes the normal cadence. The two were one number, so a single 503 spent whatever the server had last suggested — with the 30-day default that is three days. A client told to check again in three days on day 12, returning on day 29 with one day of session left and hitting one 503, would not have been allowed to retry until day 32: two days after the session it was trying to save had expired. Failures now buy a short backing-off delay of their own, reset by the next success, so recovery follows the network rather than the cadence. The bearer token is read from shared storage per request instead of cached per client. Desktop runs one ApiClient per window over one localStorage, so a session renewed in one window left the others sending a credential on its way out until it expired and took the whole session down. A 401 is also now matched against the credential that produced it: a request that went out just before a renewal can be answered after it, and treating that as expiry would tear down a session that is demonstrably alive, clearing tabs and drafts with it. Documentation catches up with both the behaviour and the new minimum, in all four languages: sessions are an idle bound rather than a countdown from sign-in, the bound is one-sided, and AUTH_TOKEN_TTL clamps up at 60s. The rationale for that minimum is corrected too — it is the 5s floor every client applies to the derived cadence, not whole-second serialisation, which only binds far below it. Also removes two claims that were not true: that a failed renewal leaves at least half a TTL (it happens inside the window, so less), and that an open untouched window never renews (cookie-mode sessions slide on any authenticated request, background polling included). Refs MUL-7436 Co-authored-by: multica-agent <[email protected]> * fix(auth): back off on a renewal response that carries no cadence A 200 whose body fails schema validation does not throw — parseWithFallback returns zeroes — so both clients reset the failure count without moving the next-check deadline, and every subsequent interaction fired another request at a server already answering badly. A missing cadence is now treated as a failure and takes the same short backoff; a token that did arrive is still applied. Also settles the documentation wording: the 30 days is not an idle timer. Renewal only happens past the halfway point, so an unused session can end sooner than 30 days after its last use, and saying otherwise promised precision the design does not have. Refs MUL-7436 Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: J <[email protected]> Co-authored-by: multica-agent <[email protected]>
848 lines
26 KiB
Go
848 lines
26 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/analytics"
|
|
"github.com/multica-ai/multica/server/internal/auth"
|
|
"github.com/multica-ai/multica/server/internal/logger"
|
|
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// SignupError represents signup restriction errors
|
|
type SignupError struct {
|
|
Message string
|
|
}
|
|
|
|
func (e SignupError) Error() string {
|
|
return e.Message
|
|
}
|
|
|
|
var ErrSignupProhibited = SignupError{Message: "user registration is disabled on this self-hosted instance"}
|
|
var ErrEmailNotAllowed = SignupError{Message: "email address or domain not allowed on this instance"}
|
|
|
|
const (
|
|
googleLoginCodeAccountDisabled = "account_disabled"
|
|
googleLoginCodeSignupProhibited = "signup_prohibited"
|
|
googleLoginCodeEmailNotAllowed = "email_not_allowed"
|
|
googleLoginCodeAccountWithoutEmail = "google_account_no_email"
|
|
googleLoginCodeInvalidOAuthCode = "oauth_code_invalid"
|
|
)
|
|
|
|
const devVerificationCodeEnv = "MULTICA_DEV_VERIFICATION_CODE"
|
|
|
|
// supportedLanguages mirrors `SUPPORTED_LOCALES` in packages/core/i18n/types.ts.
|
|
// Keep both lists in sync when adding a locale — the user-controlled `language`
|
|
// field round-trips through GetMe back into i18n.changeLanguage(), so without
|
|
// validation an arbitrary string would persist and echo to every device.
|
|
var supportedLanguages = map[string]struct{}{
|
|
"en": {},
|
|
"zh-Hans": {},
|
|
"ko": {},
|
|
"ja": {},
|
|
"fr": {},
|
|
}
|
|
|
|
type UserResponse struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
AvatarURL *string `json:"avatar_url"`
|
|
Language *string `json:"language"`
|
|
// Pinned IANA tz; nil = no preference (use browser-detected tz).
|
|
Timezone *string `json:"timezone"`
|
|
OnboardedAt *string `json:"onboarded_at"`
|
|
OnboardingQuestionnaire json.RawMessage `json:"onboarding_questionnaire"`
|
|
StarterContentState *string `json:"starter_content_state"`
|
|
ProfileDescription string `json:"profile_description"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// MaxProfileDescriptionLen caps the user-supplied profile_description body.
|
|
// Picked at 2000 chars per MUL-2406: enough room for role / stack / a few
|
|
// preferences, short enough that injecting it into every agent brief
|
|
// doesn't move the needle on prompt cost.
|
|
const MaxProfileDescriptionLen = 2000
|
|
|
|
func (h *Handler) userToResponse(u db.User) UserResponse {
|
|
// JSONB column is []byte with DEFAULT '{}', so it's never nil at the DB
|
|
// level. Defensive coalesce just in case a future ALTER makes the column
|
|
// nullable and some row comes back with no default applied.
|
|
q := u.OnboardingQuestionnaire
|
|
if len(q) == 0 {
|
|
q = []byte("{}")
|
|
}
|
|
return UserResponse{
|
|
ID: uuidToString(u.ID),
|
|
Name: u.Name,
|
|
Email: u.Email,
|
|
AvatarURL: h.resolveAvatarURLPtr(textToPtr(u.AvatarUrl)),
|
|
Language: textToPtr(u.Language),
|
|
Timezone: textToPtr(u.Timezone),
|
|
OnboardedAt: timestampToPtr(u.OnboardedAt),
|
|
OnboardingQuestionnaire: json.RawMessage(q),
|
|
StarterContentState: textToPtr(u.StarterContentState),
|
|
ProfileDescription: u.ProfileDescription,
|
|
CreatedAt: timestampToString(u.CreatedAt),
|
|
UpdatedAt: timestampToString(u.UpdatedAt),
|
|
}
|
|
}
|
|
|
|
type LoginResponse struct {
|
|
Token string `json:"token"`
|
|
User UserResponse `json:"user"`
|
|
}
|
|
|
|
type SendCodeRequest struct {
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
type VerifyCodeRequest struct {
|
|
Email string `json:"email"`
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
func generateCode() (string, error) {
|
|
var buf [4]byte
|
|
if _, err := rand.Read(buf[:]); err != nil {
|
|
return "", err
|
|
}
|
|
n := binary.BigEndian.Uint32(buf[:]) % 1000000
|
|
return fmt.Sprintf("%06d", n), nil
|
|
}
|
|
|
|
func isDevVerificationCode(code string) bool {
|
|
if isProductionEnv() {
|
|
return false
|
|
}
|
|
|
|
devCode := strings.TrimSpace(os.Getenv(devVerificationCodeEnv))
|
|
if !isSixDigitCode(devCode) {
|
|
return false
|
|
}
|
|
|
|
return subtle.ConstantTimeCompare([]byte(code), []byte(devCode)) == 1
|
|
}
|
|
|
|
func isProductionEnv() bool {
|
|
return strings.EqualFold(strings.TrimSpace(os.Getenv("APP_ENV")), "production")
|
|
}
|
|
|
|
func isSixDigitCode(code string) bool {
|
|
if len(code) != 6 {
|
|
return false
|
|
}
|
|
for _, ch := range code {
|
|
if ch < '0' || ch > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (h *Handler) issueJWT(user db.User) (string, error) {
|
|
if auth.IsTemporarilyDisabledUser(uuidToString(user.ID), user.Email) {
|
|
return "", auth.ErrTemporarilyDisabledUser
|
|
}
|
|
// `sid` identifies this login for as long as it lasts: sliding renewal
|
|
// copies it forward, so it stays put while `exp` moves. The CSRF token is
|
|
// bound to it rather than to the token string, which is what lets the
|
|
// auth cookie be re-issued mid-session without invalidating CSRF tokens
|
|
// other tabs are already holding (MUL-7436).
|
|
sid, err := auth.NewSessionID()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
|
"sub": uuidToString(user.ID),
|
|
"email": user.Email,
|
|
"name": user.Name,
|
|
"sid": sid,
|
|
"exp": time.Now().Add(auth.AuthTokenTTL()).Unix(),
|
|
"iat": time.Now().Unix(),
|
|
})
|
|
return token.SignedString(auth.JWTSecret())
|
|
}
|
|
|
|
// findOrCreateUser returns the existing user for an email, or creates one if
|
|
// none exists. isNew reports whether this call created the user — the signup
|
|
// event fires on that edge, covering both the verification-code and Google
|
|
// OAuth entry points.
|
|
func (h *Handler) findOrCreateUser(ctx context.Context, email string) (user db.User, isNew bool, err error) {
|
|
if auth.IsTemporarilyDisabledUserEmail(email) {
|
|
return db.User{}, false, auth.ErrTemporarilyDisabledUser
|
|
}
|
|
|
|
user, err = h.Queries.GetUserByEmail(ctx, email)
|
|
isNew = isNotFound(err)
|
|
if err != nil && !isNew {
|
|
return db.User{}, false, err
|
|
}
|
|
if !isNew && auth.IsTemporarilyDisabledUser(uuidToString(user.ID), user.Email) {
|
|
return db.User{}, false, auth.ErrTemporarilyDisabledUser
|
|
}
|
|
|
|
if err := h.checkSignupAllowed(email, isNew); err != nil {
|
|
return db.User{}, false, err
|
|
}
|
|
|
|
if !isNew {
|
|
return user, false, nil
|
|
}
|
|
|
|
name := email
|
|
if at := strings.Index(email, "@"); at > 0 {
|
|
name = email[:at]
|
|
}
|
|
created, err := h.Queries.CreateUser(ctx, db.CreateUserParams{
|
|
Name: name,
|
|
Email: email,
|
|
})
|
|
if err != nil {
|
|
return db.User{}, false, err
|
|
}
|
|
return created, true, nil
|
|
}
|
|
|
|
// signupSourceFromRequest reads the attribution cookie the web frontend
|
|
// sets on the first pageview (UTM + referrer bundle). The frontend writes
|
|
// a JSON string URL-encoded into the cookie value — Go does not
|
|
// auto-decode Cookie.Value, so we have to unescape here before the string
|
|
// lands in PostHog. Missing cookie / decode failures collapse to the
|
|
// empty string; that simply omits signup_source from the event rather
|
|
// than sending percent-encoded garbage. Never fall back to r.Referer() —
|
|
// the frontend has already sanitised attribution and a raw referer can
|
|
// leak OAuth code/state from the callback URL.
|
|
//
|
|
// The cap is the server-side defence against a client that manages to set
|
|
// an oversize cookie; it matches SIGNUP_SOURCE_MAX_LEN on the frontend.
|
|
const signupSourceMaxLen = 512
|
|
|
|
func signupSourceFromRequest(r *http.Request) string {
|
|
c, err := r.Cookie("multica_signup_source")
|
|
if err != nil || c == nil {
|
|
return ""
|
|
}
|
|
decoded, err := url.QueryUnescape(c.Value)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
if len(decoded) > signupSourceMaxLen {
|
|
return ""
|
|
}
|
|
return decoded
|
|
}
|
|
|
|
func (h *Handler) checkSignupAllowed(email string, isNewUser bool) error {
|
|
if !isNewUser {
|
|
return nil // existing users always allowed to log in
|
|
}
|
|
|
|
email = strings.ToLower(email)
|
|
domain := ""
|
|
if at := strings.Index(email, "@"); at > 0 {
|
|
domain = email[at+1:]
|
|
}
|
|
|
|
// 1. explicit email whitelist always wins
|
|
if len(h.cfg.AllowedEmails) > 0 && contains(h.cfg.AllowedEmails, email) {
|
|
return nil
|
|
}
|
|
|
|
// 2. domain whitelist always wins
|
|
if len(h.cfg.AllowedEmailDomains) > 0 && contains(h.cfg.AllowedEmailDomains, domain) {
|
|
return nil
|
|
}
|
|
|
|
// 3. general signup flag
|
|
if !h.cfg.AllowSignup {
|
|
return ErrSignupProhibited
|
|
}
|
|
|
|
// 4. if allowlists are set but didn't match, block
|
|
if len(h.cfg.AllowedEmailDomains) > 0 || len(h.cfg.AllowedEmails) > 0 {
|
|
return ErrEmailNotAllowed
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func contains(slice []string, s string) bool {
|
|
for _, item := range slice {
|
|
if strings.EqualFold(item, s) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
|
|
var req SendCodeRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
email := strings.ToLower(strings.TrimSpace(req.Email))
|
|
if email == "" {
|
|
writeError(w, http.StatusBadRequest, "email is required")
|
|
return
|
|
}
|
|
if auth.IsTemporarilyDisabledUserEmail(email) {
|
|
writeError(w, http.StatusForbidden, auth.TemporarilyDisabledUserError)
|
|
return
|
|
}
|
|
|
|
// Check signup restrictions before sending magic link
|
|
existingUser, err := h.Queries.GetUserByEmail(r.Context(), email)
|
|
if err != nil {
|
|
if !isNotFound(err) {
|
|
// Real database/query error → return 500
|
|
writeError(w, http.StatusInternalServerError, "failed to lookup user")
|
|
return
|
|
}
|
|
// User does not exist → treat as new user
|
|
isNewUser := true
|
|
if err := h.checkSignupAllowed(email, isNewUser); err != nil {
|
|
var signupErr SignupError
|
|
if errors.As(err, &signupErr) {
|
|
writeError(w, http.StatusForbidden, signupErr.Error())
|
|
} else {
|
|
writeError(w, http.StatusForbidden, "user registration is disabled")
|
|
}
|
|
return
|
|
}
|
|
} else {
|
|
// User already exists → always allowed to login
|
|
if auth.IsTemporarilyDisabledUser(uuidToString(existingUser.ID), existingUser.Email) {
|
|
writeError(w, http.StatusForbidden, auth.TemporarilyDisabledUserError)
|
|
return
|
|
}
|
|
isNewUser := false
|
|
if err := h.checkSignupAllowed(email, isNewUser); err != nil {
|
|
// This should rarely happen, but handle it anyway
|
|
var signupErr SignupError
|
|
if errors.As(err, &signupErr) {
|
|
writeError(w, http.StatusForbidden, signupErr.Error())
|
|
} else {
|
|
writeError(w, http.StatusForbidden, "user registration is disabled")
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
// Rate limit: max 1 code per 60 seconds per email
|
|
latest, err := h.Queries.GetLatestCodeByEmail(r.Context(), email)
|
|
if err == nil && time.Since(latest.CreatedAt.Time) < 60*time.Second {
|
|
writeError(w, http.StatusTooManyRequests, "please wait before requesting another code")
|
|
return
|
|
}
|
|
|
|
code, err := generateCode()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to generate code")
|
|
return
|
|
}
|
|
|
|
_, err = h.Queries.CreateVerificationCode(r.Context(), db.CreateVerificationCodeParams{
|
|
Email: email,
|
|
Code: code,
|
|
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(10 * time.Minute), Valid: true},
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to store verification code")
|
|
return
|
|
}
|
|
|
|
if err := h.EmailService.SendVerificationCode(email, code); err != nil {
|
|
slog.Error("failed to send verification code", "email", email, "error", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to send verification code")
|
|
return
|
|
}
|
|
|
|
// Best-effort cleanup of expired codes
|
|
_ = h.Queries.DeleteExpiredVerificationCodes(r.Context())
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"message": "Verification code sent"})
|
|
}
|
|
|
|
func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) {
|
|
var req VerifyCodeRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
email := strings.ToLower(strings.TrimSpace(req.Email))
|
|
code := strings.TrimSpace(req.Code)
|
|
|
|
if email == "" || code == "" {
|
|
writeError(w, http.StatusBadRequest, "email and code are required")
|
|
return
|
|
}
|
|
if auth.IsTemporarilyDisabledUserEmail(email) {
|
|
writeError(w, http.StatusForbidden, auth.TemporarilyDisabledUserError)
|
|
return
|
|
}
|
|
|
|
dbCode, err := h.Queries.GetLatestVerificationCode(r.Context(), email)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid or expired code")
|
|
return
|
|
}
|
|
|
|
isDevCode := isDevVerificationCode(code)
|
|
if !isDevCode && subtle.ConstantTimeCompare([]byte(code), []byte(dbCode.Code)) != 1 {
|
|
_ = h.Queries.IncrementVerificationCodeAttempts(r.Context(), dbCode.ID)
|
|
writeError(w, http.StatusBadRequest, "invalid or expired code")
|
|
return
|
|
}
|
|
|
|
if err := h.Queries.MarkVerificationCodeUsed(r.Context(), dbCode.ID); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to verify code")
|
|
return
|
|
}
|
|
|
|
user, isNew, err := h.findOrCreateUser(r.Context(), email)
|
|
if err != nil {
|
|
if errors.Is(err, auth.ErrTemporarilyDisabledUser) {
|
|
writeError(w, http.StatusForbidden, auth.TemporarilyDisabledUserError)
|
|
return
|
|
}
|
|
var signupErr SignupError
|
|
if errors.As(err, &signupErr) {
|
|
writeError(w, http.StatusForbidden, signupErr.Error())
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to create user")
|
|
return
|
|
}
|
|
if isNew {
|
|
obsmetrics.RecordEvent(h.Analytics, h.Metrics, analytics.Signup(uuidToString(user.ID), user.Email, signupSourceFromRequest(r)))
|
|
}
|
|
|
|
tokenString, err := h.issueJWT(user)
|
|
if err != nil {
|
|
if errors.Is(err, auth.ErrTemporarilyDisabledUser) {
|
|
writeError(w, http.StatusForbidden, auth.TemporarilyDisabledUserError)
|
|
return
|
|
}
|
|
slog.Warn("login failed", append(logger.RequestAttrs(r), "error", err, "email", req.Email)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to generate token")
|
|
return
|
|
}
|
|
|
|
// Set HttpOnly auth cookie (browser clients) + CSRF cookie.
|
|
if err := auth.SetAuthCookies(w, tokenString); err != nil {
|
|
slog.Warn("failed to set auth cookies", "error", err)
|
|
}
|
|
|
|
// Set CloudFront signed cookies for CDN access.
|
|
if h.CFSigner != nil {
|
|
for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(auth.AuthTokenTTL())) {
|
|
http.SetCookie(w, cookie)
|
|
}
|
|
}
|
|
|
|
slog.Info("user logged in", append(logger.RequestAttrs(r), "user_id", uuidToString(user.ID), "email", user.Email)...)
|
|
writeJSON(w, http.StatusOK, LoginResponse{
|
|
Token: tokenString,
|
|
User: h.userToResponse(user),
|
|
})
|
|
}
|
|
|
|
func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
user, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
// The credential no longer identifies an existing user. Return the
|
|
// same terminal status as an expired token so clients can sign in again.
|
|
writeError(w, http.StatusUnauthorized, "user not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to load user")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, h.userToResponse(user))
|
|
}
|
|
|
|
type UpdateMeRequest struct {
|
|
Name *string `json:"name"`
|
|
AvatarURL *string `json:"avatar_url"`
|
|
Language *string `json:"language"`
|
|
ProfileDescription *string `json:"profile_description"`
|
|
// IANA tz to pin; "" clears back to NULL; nil leaves untouched.
|
|
Timezone *string `json:"timezone"`
|
|
}
|
|
|
|
type GoogleLoginRequest struct {
|
|
Code string `json:"code"`
|
|
RedirectURI string `json:"redirect_uri"`
|
|
}
|
|
|
|
type googleTokenResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
IDToken string `json:"id_token"`
|
|
TokenType string `json:"token_type"`
|
|
}
|
|
|
|
type googleUserInfo struct {
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
Picture string `json:"picture"`
|
|
}
|
|
|
|
func writeGoogleLoginActionableError(w http.ResponseWriter, err error) bool {
|
|
switch {
|
|
case errors.Is(err, auth.ErrTemporarilyDisabledUser):
|
|
writeErrorCode(w, http.StatusForbidden, googleLoginCodeAccountDisabled, auth.TemporarilyDisabledUserError)
|
|
case errors.Is(err, ErrSignupProhibited):
|
|
writeErrorCode(w, http.StatusForbidden, googleLoginCodeSignupProhibited, ErrSignupProhibited.Error())
|
|
case errors.Is(err, ErrEmailNotAllowed):
|
|
writeErrorCode(w, http.StatusForbidden, googleLoginCodeEmailNotAllowed, ErrEmailNotAllowed.Error())
|
|
default:
|
|
var signupErr SignupError
|
|
if !errors.As(err, &signupErr) {
|
|
return false
|
|
}
|
|
// Preserve the actionable fallback for signup restrictions without a code.
|
|
writeError(w, http.StatusForbidden, signupErr.Error())
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (h *Handler) googleHTTPClient() *http.Client {
|
|
if h.googleOAuthHTTPClient != nil {
|
|
return h.googleOAuthHTTPClient
|
|
}
|
|
return http.DefaultClient
|
|
}
|
|
|
|
func (h *Handler) GoogleLogin(w http.ResponseWriter, r *http.Request) {
|
|
var req GoogleLoginRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
if req.Code == "" {
|
|
writeError(w, http.StatusBadRequest, "code is required")
|
|
return
|
|
}
|
|
|
|
clientID := os.Getenv("GOOGLE_CLIENT_ID")
|
|
clientSecret := os.Getenv("GOOGLE_CLIENT_SECRET")
|
|
if clientID == "" || clientSecret == "" {
|
|
writeFeatureDisabled(w, "google_login_not_configured", "Google login is not configured")
|
|
return
|
|
}
|
|
|
|
redirectURI := req.RedirectURI
|
|
if redirectURI == "" {
|
|
redirectURI = os.Getenv("GOOGLE_REDIRECT_URI")
|
|
}
|
|
|
|
// Exchange authorization code for tokens.
|
|
tokenResp, err := h.googleHTTPClient().PostForm("https://oauth2.googleapis.com/token", url.Values{
|
|
"code": {req.Code},
|
|
"client_id": {clientID},
|
|
"client_secret": {clientSecret},
|
|
"redirect_uri": {redirectURI},
|
|
"grant_type": {"authorization_code"},
|
|
})
|
|
if err != nil {
|
|
slog.Error("google oauth token exchange failed", "error", err)
|
|
writeError(w, http.StatusBadGateway, "failed to exchange code with Google")
|
|
return
|
|
}
|
|
defer tokenResp.Body.Close()
|
|
|
|
tokenBody, err := io.ReadAll(tokenResp.Body)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "failed to read Google token response")
|
|
return
|
|
}
|
|
|
|
if tokenResp.StatusCode != http.StatusOK {
|
|
slog.Error("google oauth token exchange returned error", "status", tokenResp.StatusCode, "body", string(tokenBody))
|
|
var tokenErr struct {
|
|
Error string `json:"error"`
|
|
}
|
|
// Only a valid invalid_grant response identifies a rejected authorization
|
|
// code. Configuration, provider and malformed responses are server failures.
|
|
if err := json.Unmarshal(tokenBody, &tokenErr); err == nil &&
|
|
tokenResp.StatusCode == http.StatusBadRequest && tokenErr.Error == "invalid_grant" {
|
|
writeErrorCode(w, http.StatusBadRequest, googleLoginCodeInvalidOAuthCode, "failed to exchange code with Google")
|
|
return
|
|
}
|
|
writeError(w, http.StatusBadGateway, "failed to exchange code with Google")
|
|
return
|
|
}
|
|
|
|
var gToken googleTokenResponse
|
|
if err := json.Unmarshal(tokenBody, &gToken); err != nil {
|
|
writeError(w, http.StatusBadGateway, "failed to parse Google token response")
|
|
return
|
|
}
|
|
if strings.TrimSpace(gToken.AccessToken) == "" {
|
|
slog.Error("google oauth token response has no access token")
|
|
writeError(w, http.StatusBadGateway, "invalid Google token response")
|
|
return
|
|
}
|
|
|
|
// Fetch user info from Google.
|
|
userInfoReq, err := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://www.googleapis.com/oauth2/v2/userinfo", nil)
|
|
if err != nil {
|
|
slog.Error("failed to create userinfo request", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
userInfoReq.Header.Set("Authorization", "Bearer "+gToken.AccessToken)
|
|
|
|
userInfoResp, err := h.googleHTTPClient().Do(userInfoReq)
|
|
if err != nil {
|
|
slog.Error("google userinfo fetch failed", "error", err)
|
|
writeError(w, http.StatusBadGateway, "failed to fetch user info from Google")
|
|
return
|
|
}
|
|
defer userInfoResp.Body.Close()
|
|
|
|
if userInfoResp.StatusCode != http.StatusOK {
|
|
body, readErr := io.ReadAll(io.LimitReader(userInfoResp.Body, 4096))
|
|
if readErr != nil {
|
|
slog.Error("google userinfo error response could not be read", "status", userInfoResp.StatusCode, "error", readErr)
|
|
} else {
|
|
slog.Error("google userinfo returned error", "status", userInfoResp.StatusCode, "body", string(body))
|
|
}
|
|
writeError(w, http.StatusBadGateway, "failed to fetch user info from Google")
|
|
return
|
|
}
|
|
|
|
var gUser *googleUserInfo
|
|
if err := json.NewDecoder(userInfoResp.Body).Decode(&gUser); err != nil {
|
|
writeError(w, http.StatusBadGateway, "failed to parse Google user info")
|
|
return
|
|
}
|
|
if gUser == nil {
|
|
writeError(w, http.StatusBadGateway, "invalid Google user info")
|
|
return
|
|
}
|
|
|
|
email := strings.ToLower(strings.TrimSpace(gUser.Email))
|
|
if email == "" {
|
|
writeErrorCode(w, http.StatusBadRequest, googleLoginCodeAccountWithoutEmail, "Google did not provide an email address for this sign-in")
|
|
return
|
|
}
|
|
|
|
if auth.IsTemporarilyDisabledUserEmail(email) {
|
|
writeErrorCode(w, http.StatusForbidden, googleLoginCodeAccountDisabled, auth.TemporarilyDisabledUserError)
|
|
return
|
|
}
|
|
|
|
user, isNew, err := h.findOrCreateUser(r.Context(), email)
|
|
if err != nil {
|
|
if writeGoogleLoginActionableError(w, err) {
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to create user")
|
|
return
|
|
}
|
|
if isNew {
|
|
evt := analytics.Signup(uuidToString(user.ID), user.Email, signupSourceFromRequest(r))
|
|
evt.Properties["auth_method"] = "google"
|
|
obsmetrics.RecordEvent(h.Analytics, h.Metrics, evt)
|
|
}
|
|
|
|
// Update name and avatar from Google profile if the user was just created
|
|
// (default name is email prefix) or has no avatar yet.
|
|
needsUpdate := false
|
|
newName := user.Name
|
|
newAvatar := user.AvatarUrl
|
|
|
|
if gUser.Name != "" && user.Name == strings.Split(email, "@")[0] {
|
|
newName = gUser.Name
|
|
needsUpdate = true
|
|
}
|
|
if gUser.Picture != "" && !user.AvatarUrl.Valid {
|
|
newAvatar = pgtype.Text{String: gUser.Picture, Valid: true}
|
|
needsUpdate = true
|
|
}
|
|
|
|
if needsUpdate {
|
|
updated, err := h.Queries.UpdateUser(r.Context(), db.UpdateUserParams{
|
|
ID: user.ID,
|
|
Name: newName,
|
|
AvatarUrl: newAvatar,
|
|
})
|
|
if err == nil {
|
|
user = updated
|
|
}
|
|
}
|
|
|
|
tokenString, err := h.issueJWT(user)
|
|
if err != nil {
|
|
if writeGoogleLoginActionableError(w, err) {
|
|
return
|
|
}
|
|
slog.Warn("google login failed", append(logger.RequestAttrs(r), "error", err, "email", email)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to generate token")
|
|
return
|
|
}
|
|
|
|
if err := auth.SetAuthCookies(w, tokenString); err != nil {
|
|
slog.Warn("failed to set auth cookies", "error", err)
|
|
}
|
|
|
|
if h.CFSigner != nil {
|
|
for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(72 * time.Hour)) {
|
|
http.SetCookie(w, cookie)
|
|
}
|
|
}
|
|
|
|
slog.Info("user logged in via google", append(logger.RequestAttrs(r), "user_id", uuidToString(user.ID), "email", user.Email)...)
|
|
writeJSON(w, http.StatusOK, LoginResponse{
|
|
Token: tokenString,
|
|
User: h.userToResponse(user),
|
|
})
|
|
}
|
|
|
|
// IssueCliToken returns a fresh JWT for the authenticated user.
|
|
// This allows cookie-authenticated browser sessions to obtain a bearer token
|
|
// that can be handed off to the CLI via the cli_callback redirect.
|
|
func (h *Handler) IssueCliToken(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
user, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "user not found")
|
|
return
|
|
}
|
|
|
|
tokenString, err := h.issueJWT(user)
|
|
if err != nil {
|
|
if errors.Is(err, auth.ErrTemporarilyDisabledUser) {
|
|
writeError(w, http.StatusForbidden, auth.TemporarilyDisabledUserError)
|
|
return
|
|
}
|
|
slog.Warn("cli-token: failed to issue JWT", append(logger.RequestAttrs(r), "error", err, "user_id", userID)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to generate token")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"token": tokenString})
|
|
}
|
|
|
|
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
|
auth.ClearAuthCookies(w)
|
|
writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"})
|
|
}
|
|
|
|
func (h *Handler) UpdateMe(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req UpdateMeRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
currentUser, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "user not found")
|
|
return
|
|
}
|
|
|
|
name := currentUser.Name
|
|
if req.Name != nil {
|
|
name = strings.TrimSpace(*req.Name)
|
|
if name == "" {
|
|
writeError(w, http.StatusBadRequest, "name is required")
|
|
return
|
|
}
|
|
}
|
|
|
|
params := db.UpdateUserParams{
|
|
ID: currentUser.ID,
|
|
Name: name,
|
|
}
|
|
if req.AvatarURL != nil {
|
|
avatarURL, ok := h.acceptAvatarURL(w, r, *req.AvatarURL, currentUser.AvatarUrl.String)
|
|
if !ok {
|
|
return
|
|
}
|
|
params.AvatarUrl = pgtype.Text{String: avatarURL, Valid: true}
|
|
}
|
|
if req.Language != nil {
|
|
lang := strings.TrimSpace(*req.Language)
|
|
if _, ok := supportedLanguages[lang]; !ok {
|
|
writeError(w, http.StatusBadRequest, "unsupported language")
|
|
return
|
|
}
|
|
params.Language = pgtype.Text{String: lang, Valid: true}
|
|
}
|
|
if req.ProfileDescription != nil {
|
|
// Count runes, not bytes: 2000 chars of Chinese must not be rejected
|
|
// as ~6000 bytes. utf8.RuneCountInString handles invalid UTF-8 by
|
|
// counting each bad byte as one rune, which still bounds the column.
|
|
desc := strings.TrimSpace(*req.ProfileDescription)
|
|
if utf8.RuneCountInString(desc) > MaxProfileDescriptionLen {
|
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("profile_description exceeds %d characters", MaxProfileDescriptionLen))
|
|
return
|
|
}
|
|
params.ProfileDescription = pgtype.Text{String: desc, Valid: true}
|
|
}
|
|
|
|
if req.Timezone != nil {
|
|
// Valid=false → column untouched; Valid=true + "" → clear to
|
|
// NULL; Valid=true + IANA → set. Three-way semantics enforced
|
|
// in the UpdateUser SQL CASE.
|
|
tz := strings.TrimSpace(*req.Timezone)
|
|
if tz != "" {
|
|
if loc, err := time.LoadLocation(tz); err != nil || loc == nil {
|
|
writeError(w, http.StatusBadRequest, "invalid timezone")
|
|
return
|
|
}
|
|
}
|
|
params.Timezone = pgtype.Text{String: tz, Valid: true}
|
|
}
|
|
|
|
updatedUser, err := h.Queries.UpdateUser(r.Context(), params)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to update user")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, h.userToResponse(updatedUser))
|
|
}
|