mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* feat(b2-2): declare protocol_epoch in the schema and generate both constants protocol/schema.json gains protocol_epoch (1). genprotocol emits ws.ProtocolEpoch and PROTOCOL_EPOCH from it; the contract test pins the Go constant to the schema so a stale regeneration fails the required check. * feat(b2-2): check the client's protocol epoch in the auth handshake The auth payload gains epoch (absent = 0). Outside [minClientEpoch, ProtocolEpoch] the server answers one auth_error with code protocol_epoch_unsupported, the client/server/min epochs, and a message naming which side to update, then closes 1008 like every other handshake failure. minClientEpoch is 0 for epoch 1 only so alpha.4 clients keep connecting; the epoch-1 fixtures are unchanged. * feat(b2-2): send the protocol epoch and offer the update on a refused connect ws.ts sends epoch: PROTOCOL_EPOCH in the auth frame (contract test extended on purpose). On auth_error code protocol_epoch_unsupported with a newer server the dispatcher records the host in ui.store.updateRequiredHost and main.ts mounts the UpdateNotifier on the connect page, so a refused client gets the same Update Now banner it would have had on the main page. * feat(b2-2): withhold client releases newer than the server's protocol epoch The signed server-update manifest gains protocol_epoch (release.yml reads it from protocol/schema.json). Updater.ReleaseProtocolEpoch verifies the manifest and reads it; the client-update endpoint answers 204 when the release's epoch is newer than ws.ProtocolEpoch or the manifest does not verify. Releases without a manifest are epoch 0 and advertised as before. Docs: protocol.md Compatibility section, api.md, deployment.md, protocol README, CHANGELOG Unreleased. * docs(b2-2): record the slim B2-2 decision and evidence; fold B2-3/B2-4 into it * ci: prove the protocol_epoch manifest read on every PR, not only at tag time * fix(b2-2): offer the update on an already-mounted connect page and keep the credential on a protocol refusal Codex P1: on a first login or startup auto-login no overlay exists before auth_ok, so a refusal never re-rendered the connect page and the one-time read of updateRequiredHost missed it. The connect page now subscribes to it, and a later refusal replaces the banner. Codex P2: a refusal on reconnect went through the generic logout and deleted the stored credential although the token is still valid. clearAuth gets a protocol_epoch reason; main.ts keeps the credential on it (the skip-auto-login flag is still set and, being sessionStorage, does not survive the relaunch the update triggers).
106 lines
4.2 KiB
Go
106 lines
4.2 KiB
Go
package ws
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/coder/websocket"
|
|
|
|
"github.com/J3vb/OwnCord/Server/auth"
|
|
"github.com/J3vb/OwnCord/Server/db"
|
|
)
|
|
|
|
// authenticateConn reads the first WebSocket message and validates the session
|
|
// token. Returns the authenticated user and the token hash (for later
|
|
// periodic session revalidation).
|
|
// resumeHint carries the client-supplied reconnect hints from the auth frame.
|
|
// Both fields are UNTRUSTED attacker-controlled input: LastSeq only ever
|
|
// narrows what replay will send, and ChannelID is checked against the allowed
|
|
// set before it is honoured (see handleReconnect).
|
|
type resumeHint struct {
|
|
LastSeq uint64
|
|
ChannelID int64
|
|
}
|
|
|
|
func authenticateConn(parent context.Context, conn *websocket.Conn, database *db.DB) (*db.User, string, resumeHint, error) {
|
|
ctx, cancel := context.WithTimeout(parent, authDeadline)
|
|
defer cancel()
|
|
|
|
_, raw, err := conn.Read(ctx)
|
|
if err != nil {
|
|
return nil, "", resumeHint{}, err
|
|
}
|
|
|
|
var env envelope
|
|
if err := json.Unmarshal(raw, &env); err != nil {
|
|
_ = conn.Write(ctx, websocket.MessageText, buildAuthError("invalid message"))
|
|
return nil, "", resumeHint{}, fmt.Errorf("auth: invalid JSON: %w", err)
|
|
}
|
|
if env.Type != MsgTypeAuth {
|
|
_ = conn.Write(ctx, websocket.MessageText, buildAuthError("first message must be auth"))
|
|
return nil, "", resumeHint{}, fmt.Errorf("auth: unexpected type %q", env.Type)
|
|
}
|
|
|
|
var p struct {
|
|
Token string `json:"token"`
|
|
LastSeq uint64 `json:"last_seq"`
|
|
// ActiveChannelID lets a resuming client re-declare the channel it had
|
|
// open, so the server can restore its ChannelTopic subscription during
|
|
// the handshake instead of leaving it unsubscribed until the
|
|
// post-auth_ok channel_focus round trip lands.
|
|
ActiveChannelID int64 `json:"active_channel_id"`
|
|
// Epoch is the wire epoch the client speaks (docs/protocol.md,
|
|
// Compatibility). Absent means 0: clients up to v1.2.0-alpha.4 predate
|
|
// the field.
|
|
Epoch int `json:"epoch"`
|
|
}
|
|
if err := json.Unmarshal(env.Payload, &p); err != nil || p.Token == "" {
|
|
_ = conn.Write(ctx, websocket.MessageText, buildAuthError("missing token"))
|
|
return nil, "", resumeHint{}, fmt.Errorf("auth: missing token")
|
|
}
|
|
if p.Epoch < minClientEpoch || p.Epoch > ProtocolEpoch {
|
|
_ = conn.Write(ctx, websocket.MessageText, buildProtocolEpochError(p.Epoch))
|
|
return nil, "", resumeHint{}, fmt.Errorf("auth: protocol epoch %d outside [%d, %d]", p.Epoch, minClientEpoch, ProtocolEpoch)
|
|
}
|
|
|
|
hash := auth.HashToken(p.Token)
|
|
sess, err := database.GetSessionByTokenHash(ctx, hash)
|
|
if err != nil {
|
|
// DB outage, not a bad token — send a non-terminal error frame so the
|
|
// client's normal backoff/reconnect logic retries instead of treating
|
|
// this like a genuinely invalid session (buildAuthError is defined as
|
|
// non-recoverable on the wire: the client stops reconnecting and
|
|
// clears its stored credentials on that frame).
|
|
_ = conn.Write(ctx, websocket.MessageText, buildErrorMsg(ErrCodeInternal, "temporary failure, please retry"))
|
|
return nil, "", resumeHint{}, fmt.Errorf("auth: session lookup failed: %w", err)
|
|
}
|
|
if sess == nil {
|
|
_ = conn.Write(ctx, websocket.MessageText, buildAuthError("invalid token"))
|
|
return nil, "", resumeHint{}, fmt.Errorf("auth: invalid session")
|
|
}
|
|
|
|
if auth.IsSessionExpired(sess.ExpiresAt) {
|
|
_ = conn.Write(ctx, websocket.MessageText, buildAuthError("session expired"))
|
|
return nil, "", resumeHint{}, fmt.Errorf("auth: session expired")
|
|
}
|
|
|
|
user, err := database.GetUserByID(ctx, sess.UserID)
|
|
if err != nil {
|
|
// Same DB-outage-vs-bad-credential distinction as above.
|
|
_ = conn.Write(ctx, websocket.MessageText, buildErrorMsg(ErrCodeInternal, "temporary failure, please retry"))
|
|
return nil, "", resumeHint{}, fmt.Errorf("auth: user lookup failed: %w", err)
|
|
}
|
|
if user == nil {
|
|
_ = conn.Write(ctx, websocket.MessageText, buildAuthError("user not found"))
|
|
return nil, "", resumeHint{}, fmt.Errorf("auth: user not found")
|
|
}
|
|
|
|
if auth.IsEffectivelyBanned(user) {
|
|
_ = conn.Write(ctx, websocket.MessageText, buildErrorMsg(ErrCodeBanned, "you are banned"))
|
|
return nil, "", resumeHint{}, fmt.Errorf("auth: banned user %d", user.ID)
|
|
}
|
|
|
|
return user, hash, resumeHint{LastSeq: p.LastSeq, ChannelID: p.ActiveChannelID}, nil
|
|
}
|