103878047c
* fix(auth): scope in-session agent MCP calls to the task owner The MCP tools an agent gets automatically inside its own session are relayed over the agent's WebSocket stream, which carries no credential. The backend dispatched them on the raw stream context, so the task service saw no identity — its signal for an internal caller (event bus, pollers, office schedulers) — and served the request unscoped. An agent that supplied another user's task_id or workflow_id was given their data, violating the rule that knowing an ID must not grant access. Resolve the owning user from the stream's own AgentExecution (task -> workspace -> owner) and attach that real stored identity before dispatch, so the existing authorize* checks apply exactly as they do on the PAT-authenticated /mcp endpoint. The owning task never comes from the agent-supplied payload: honoring a payload session_id would let an agent name another user's session and inherit their identity. Tool handlers are unchanged. Auth disabled stays unscoped, unclaimed pre-auth workspaces stay visible to everyone, and a lookup failure denies the dispatch rather than falling back to full access. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(auth): never leave in-session MCP dispatch unscoped Review follow-up. Three paths still returned an identity-free context under enforced auth, and "no identity" is exactly what the task service reads as an internal caller and serves unscoped: - Unowned workspace. CreateWorkspace only stamps an owner for scoped callers, so internal callers keep producing owner_id='' rows after setup. Those streams got full cross-user access. Scope them to a sentinel user ID no account can hold: the service's "empty owner_id or my own ID" rule then limits them to the unowned rows that are public by the compatibility contract, and nothing else. - Task with no workspace. Same treatment, same reasoning. - Missing task or workspace row. A lookup that reports not-found as (nil, nil) fell through to unscoped; treat it as a resolution failure. Also deny, rather than fabricate a member identity, when the owner's account is gone or disabled. Disabling a user revokes their sessions and PATs, so a still-running agent session was the one remaining way into their workspace. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
187 lines
6.2 KiB
Go
187 lines
6.2 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/auth/authn"
|
|
"github.com/kandev/kandev/internal/auth/store"
|
|
usermodels "github.com/kandev/kandev/internal/user/models"
|
|
)
|
|
|
|
// Login verifies email+password and mints a browser session. The returned
|
|
// string is the raw session token to place in the cookie.
|
|
func (s *Service) Login(ctx context.Context, email, password, userAgent, ip string) (*usermodels.User, string, error) {
|
|
email = normalizeEmail(email)
|
|
limiterKey := ip + "|" + email
|
|
if !s.limiter.Allow(limiterKey) {
|
|
return nil, "", ErrRateLimited
|
|
}
|
|
user, err := s.users.GetUserByEmail(ctx, email)
|
|
if err != nil {
|
|
// Equalize timing with the real verification path.
|
|
_ = VerifyPassword(password, s.dummyHash)
|
|
return nil, "", ErrInvalidCredentials
|
|
}
|
|
if user.Status != usermodels.StatusActive {
|
|
_ = VerifyPassword(password, s.dummyHash)
|
|
return nil, "", ErrInvalidCredentials
|
|
}
|
|
identity, err := s.store.GetLocalIdentity(ctx, user.ID)
|
|
if err != nil {
|
|
_ = VerifyPassword(password, s.dummyHash)
|
|
return nil, "", ErrInvalidCredentials
|
|
}
|
|
if err := VerifyPassword(password, identity.PasswordHash); err != nil {
|
|
return nil, "", ErrInvalidCredentials
|
|
}
|
|
s.limiter.Reset(limiterKey)
|
|
token, err := s.createSession(ctx, user.ID, userAgent, ip)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
return user, token, nil
|
|
}
|
|
|
|
// Logout deletes the session backing the current cookie.
|
|
func (s *Service) Logout(ctx context.Context, sessionID, userID string) error {
|
|
return s.store.DeleteSession(ctx, sessionID, userID)
|
|
}
|
|
|
|
// ChangePassword verifies the current password, stores the new hash, and
|
|
// revokes every other session of the user.
|
|
func (s *Service) ChangePassword(ctx context.Context, userID, currentSessionID, current, newPassword string) error {
|
|
if len(newPassword) < minPasswordLength {
|
|
return ErrValidation
|
|
}
|
|
identity, err := s.store.GetLocalIdentity(ctx, userID)
|
|
if err != nil {
|
|
return ErrInvalidCredentials
|
|
}
|
|
if err := VerifyPassword(current, identity.PasswordHash); err != nil {
|
|
return ErrInvalidCredentials
|
|
}
|
|
hash, err := HashPassword(newPassword)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.store.UpdatePasswordHash(ctx, userID, hash); err != nil {
|
|
return err
|
|
}
|
|
return s.store.DeleteSessionsByUser(ctx, userID, currentSessionID)
|
|
}
|
|
|
|
// ResolveSessionToken authenticates a raw session-cookie value.
|
|
func (s *Service) ResolveSessionToken(ctx context.Context, token string) (authn.Identity, bool) {
|
|
if token == "" {
|
|
return authn.Identity{}, false
|
|
}
|
|
sess, err := s.store.GetSessionByTokenHash(ctx, HashToken(token))
|
|
if err != nil {
|
|
return authn.Identity{}, false
|
|
}
|
|
now := time.Now().UTC()
|
|
if sess.ExpiresAt.Before(now) {
|
|
_ = s.store.DeleteSession(ctx, sess.ID, sess.UserID)
|
|
return authn.Identity{}, false
|
|
}
|
|
user, err := s.activeUser(ctx, sess.UserID)
|
|
if err != nil {
|
|
return authn.Identity{}, false
|
|
}
|
|
if now.Sub(sess.LastSeenAt) > sessionTouchInterval {
|
|
_ = s.store.TouchSession(ctx, sess.ID, now, now.Add(s.SessionTTL()))
|
|
}
|
|
return authn.Identity{UserID: user.ID, Role: roleOf(user), SessionID: sess.ID}, true
|
|
}
|
|
|
|
// ResolveBearer authenticates an Authorization bearer credential when it is a
|
|
// kandev personal access token. Non-PAT bearers (e.g. office agent JWTs)
|
|
// return false so the caller can defer to their own validators.
|
|
func (s *Service) ResolveBearer(ctx context.Context, token string) (authn.Identity, bool) {
|
|
if !IsPATFormat(token) {
|
|
return authn.Identity{}, false
|
|
}
|
|
record, err := s.store.GetAPITokenByHash(ctx, HashToken(token))
|
|
if err != nil {
|
|
return authn.Identity{}, false
|
|
}
|
|
now := time.Now().UTC()
|
|
if record.ExpiresAt != nil && record.ExpiresAt.Before(now) {
|
|
return authn.Identity{}, false
|
|
}
|
|
user, err := s.activeUser(ctx, record.UserID)
|
|
if err != nil {
|
|
return authn.Identity{}, false
|
|
}
|
|
if record.LastUsedAt == nil || now.Sub(*record.LastUsedAt) > sessionTouchInterval {
|
|
_ = s.store.TouchAPIToken(ctx, record.ID, now)
|
|
}
|
|
return authn.Identity{UserID: user.ID, Role: roleOf(user), TokenID: record.ID}, true
|
|
}
|
|
|
|
// IdentityForUser resolves a stored user ID to the identity that user carries
|
|
// on an authenticated request, without needing one of their credentials.
|
|
//
|
|
// It exists for callers that already know *whose* work they are performing but
|
|
// have no request to authenticate: in-session agent MCP calls arrive over the
|
|
// agent's own WebSocket stream, so the owning user is derived from the stream's
|
|
// task rather than from a cookie or PAT (see internal/mcp/scope). The returned
|
|
// identity is real — never synthetic — so per-user scoping applies to it.
|
|
//
|
|
// Returns false while authentication is disabled (callers keep the pre-auth
|
|
// unscoped behavior) and when the account is missing or no longer active.
|
|
func (s *Service) IdentityForUser(ctx context.Context, userID string) (authn.Identity, bool) {
|
|
if s == nil || userID == "" || s.Mode() == ModeDisabled {
|
|
return authn.Identity{}, false
|
|
}
|
|
user, err := s.activeUser(ctx, userID)
|
|
if err != nil {
|
|
return authn.Identity{}, false
|
|
}
|
|
return authn.Identity{UserID: user.ID, Role: roleOf(user)}, true
|
|
}
|
|
|
|
// ListSessions returns the user's sessions for the account page.
|
|
func (s *Service) ListSessions(ctx context.Context, userID string) ([]*store.Session, error) {
|
|
return s.store.ListSessionsByUser(ctx, userID)
|
|
}
|
|
|
|
// RevokeSession deletes one of the user's sessions.
|
|
func (s *Service) RevokeSession(ctx context.Context, sessionID, userID string) error {
|
|
return s.store.DeleteSession(ctx, sessionID, userID)
|
|
}
|
|
|
|
func (s *Service) activeUser(ctx context.Context, userID string) (*usermodels.User, error) {
|
|
user, err := s.users.GetUser(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if user.Status != usermodels.StatusActive {
|
|
return nil, errors.New("user disabled")
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
func (s *Service) createSession(ctx context.Context, userID, userAgent, ip string) (string, error) {
|
|
token, hash, err := GenerateSessionToken()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
now := time.Now().UTC()
|
|
session := &store.Session{
|
|
UserID: userID,
|
|
TokenHash: hash,
|
|
CreatedAt: now,
|
|
ExpiresAt: now.Add(s.SessionTTL()),
|
|
LastSeenAt: now,
|
|
UserAgent: truncateUserAgent(userAgent),
|
|
IP: ip,
|
|
}
|
|
if err := s.store.CreateSession(ctx, session); err != nil {
|
|
return "", err
|
|
}
|
|
return token, nil
|
|
}
|