Files
relata/internal/storage/mongo/auth.go
T
2026-08-11 10:40:10 +01:00

280 lines
7.7 KiB
Go

package mongo
import (
"context"
"strings"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"github.com/andydixon/relata/internal/domain"
)
// UserRepo stores local accounts.
type UserRepo struct{ base }
var _ domain.UserRepository = (*UserRepo)(nil)
// Create inserts a user. Usernames are stored lowercased and a unique index
// enforces that two accounts cannot differ only by case.
func (r *UserRepo) Create(ctx context.Context, u *domain.User) error {
ctx, cancel := r.ctx(ctx)
defer cancel()
started := time.Now()
u.Username = strings.ToLower(strings.TrimSpace(u.Username))
_, err := r.coll.InsertOne(ctx, u)
r.observe("create", started, err)
return translate(err)
}
// GetByUsername looks up an account for authentication.
func (r *UserRepo) GetByUsername(ctx context.Context, username string) (*domain.User, error) {
ctx, cancel := r.ctx(ctx)
defer cancel()
started := time.Now()
f := bson.D{{Key: "username", Value: strings.ToLower(strings.TrimSpace(username))}}
var u domain.User
err := r.coll.FindOne(ctx, f).Decode(&u)
r.observe("get_by_username", started, err)
if err != nil {
return nil, translate(err)
}
return &u, nil
}
// GetByOAuthSubject looks up a federated account.
//
// The filter is the (provider, subject) pair and nothing else. Falling back to
// the email address would let a provider that recycled one hand over the
// previous holder's workspace.
func (r *UserRepo) GetByOAuthSubject(ctx context.Context, provider, subject string) (*domain.User, error) {
ctx, cancel := r.ctx(ctx)
defer cancel()
started := time.Now()
provider, subject = strings.TrimSpace(provider), strings.TrimSpace(subject)
if provider == "" || subject == "" {
return nil, domain.ErrNotFound
}
f := bson.D{
{Key: "oauth_provider", Value: provider},
{Key: "oauth_subject", Value: subject},
}
var u domain.User
err := r.coll.FindOne(ctx, f).Decode(&u)
r.observe("get_by_oauth_subject", started, err)
if err != nil {
return nil, translate(err)
}
return &u, nil
}
// GetByID fetches a user by ID.
func (r *UserRepo) GetByID(ctx context.Context, id string) (*domain.User, error) {
ctx, cancel := r.ctx(ctx)
defer cancel()
started := time.Now()
var u domain.User
err := r.coll.FindOne(ctx, bson.D{{Key: "_id", Value: id}}).Decode(&u)
r.observe("get_by_id", started, err)
if err != nil {
return nil, translate(err)
}
return &u, nil
}
// Update replaces a user document.
func (r *UserRepo) Update(ctx context.Context, u *domain.User) error {
ctx, cancel := r.ctx(ctx)
defer cancel()
started := time.Now()
u.UpdatedAt = time.Now().UTC()
res, err := r.coll.ReplaceOne(ctx, bson.D{{Key: "_id", Value: u.ID}}, u)
r.observe("update", started, err)
if err != nil {
return translate(err)
}
if res.MatchedCount == 0 {
return domain.ErrNotFound
}
return nil
}
// Count returns the number of accounts, used to detect first-run setup.
func (r *UserRepo) Count(ctx context.Context) (int64, error) {
ctx, cancel := r.ctx(ctx)
defer cancel()
started := time.Now()
n, err := r.coll.CountDocuments(ctx, bson.D{})
r.observe("count", started, err)
return n, translate(err)
}
// ---------------------------------------------------------------------------
// SessionRepo stores browser sessions.
type SessionRepo struct{ base }
var _ domain.SessionRepository = (*SessionRepo)(nil)
// Create inserts a session record.
func (r *SessionRepo) Create(ctx context.Context, s *domain.Session) error {
ctx, cancel := r.ctx(ctx)
defer cancel()
started := time.Now()
_, err := r.coll.InsertOne(ctx, s)
r.observe("create", started, err)
return translate(err)
}
// GetByTokenHash looks up a session by the hash of its cookie token.
//
// The lookup is by hash, never by the token itself, so the stored record cannot
// be replayed as a credential by anyone who can read the database.
func (r *SessionRepo) GetByTokenHash(ctx context.Context, hash string) (*domain.Session, error) {
ctx, cancel := r.ctx(ctx)
defer cancel()
started := time.Now()
var s domain.Session
err := r.coll.FindOne(ctx, bson.D{{Key: "token_hash", Value: hash}}).Decode(&s)
r.observe("get_by_token", started, err)
if err != nil {
return nil, translate(err)
}
return &s, nil
}
// Touch extends a session's idle expiry.
func (r *SessionRepo) Touch(ctx context.Context, id string, lastSeen, expires time.Time) error {
ctx, cancel := r.ctx(ctx)
defer cancel()
started := time.Now()
_, err := r.coll.UpdateOne(ctx,
bson.D{{Key: "_id", Value: id}},
bson.D{{Key: "$set", Value: bson.D{
{Key: "last_seen_at", Value: lastSeen},
{Key: "expires_at", Value: expires},
}}})
r.observe("touch", started, err)
return translate(err)
}
// Revoke invalidates one session.
func (r *SessionRepo) Revoke(ctx context.Context, id string) error {
ctx, cancel := r.ctx(ctx)
defer cancel()
started := time.Now()
_, err := r.coll.UpdateOne(ctx,
bson.D{{Key: "_id", Value: id}},
bson.D{{Key: "$set", Value: bson.D{{Key: "revoked", Value: true}}}})
r.observe("revoke", started, err)
return translate(err)
}
// RevokeAllForUser invalidates every session belonging to a user.
func (r *SessionRepo) RevokeAllForUser(ctx context.Context, userID string) error {
ctx, cancel := r.ctx(ctx)
defer cancel()
started := time.Now()
_, err := r.coll.UpdateMany(ctx,
bson.D{{Key: "user_id", Value: userID}},
bson.D{{Key: "$set", Value: bson.D{{Key: "revoked", Value: true}}}})
r.observe("revoke_all", started, err)
return translate(err)
}
// DeleteExpired removes sessions past their absolute expiry. A TTL index does
// this too; the explicit sweep makes the behaviour testable and prompt.
func (r *SessionRepo) DeleteExpired(ctx context.Context, before time.Time) (int64, error) {
ctx, cancel := r.ctx(ctx)
defer cancel()
started := time.Now()
res, err := r.coll.DeleteMany(ctx, bson.D{
{Key: "absolute_expiry", Value: bson.D{{Key: "$lt", Value: before}}},
})
r.observe("delete_expired", started, err)
if err != nil {
return 0, translate(err)
}
return res.DeletedCount, nil
}
// ---------------------------------------------------------------------------
// WorkspaceRepo stores workspaces.
type WorkspaceRepo struct{ base }
var _ domain.WorkspaceRepository = (*WorkspaceRepo)(nil)
// Create inserts a workspace.
func (r *WorkspaceRepo) Create(ctx context.Context, w *domain.Workspace) error {
ctx, cancel := r.ctx(ctx)
defer cancel()
started := time.Now()
_, err := r.coll.InsertOne(ctx, w)
r.observe("create", started, err)
return translate(err)
}
// Get fetches a workspace by ID.
func (r *WorkspaceRepo) Get(ctx context.Context, id string) (*domain.Workspace, error) {
ctx, cancel := r.ctx(ctx)
defer cancel()
started := time.Now()
var w domain.Workspace
err := r.coll.FindOne(ctx, bson.D{{Key: "_id", Value: id}}).Decode(&w)
r.observe("get", started, err)
if err != nil {
return nil, translate(err)
}
return &w, nil
}
// GetBySlug fetches a workspace by its URL-safe name.
func (r *WorkspaceRepo) GetBySlug(ctx context.Context, slug string) (*domain.Workspace, error) {
ctx, cancel := r.ctx(ctx)
defer cancel()
started := time.Now()
var w domain.Workspace
err := r.coll.FindOne(ctx, bson.D{{Key: "slug", Value: slug}}).Decode(&w)
r.observe("get_by_slug", started, err)
if err != nil {
return nil, translate(err)
}
return &w, nil
}
// List returns every workspace.
func (r *WorkspaceRepo) List(ctx context.Context) ([]*domain.Workspace, error) {
ctx, cancel := r.ctx(ctx)
defer cancel()
started := time.Now()
cur, err := r.coll.Find(ctx, bson.D{}, options.Find().SetSort(bson.D{{Key: "created_at", Value: 1}}))
r.observe("list", started, err)
if err != nil {
return nil, translate(err)
}
var out []*domain.Workspace
if err := cur.All(ctx, &out); err != nil {
return nil, err
}
return out, nil
}