diff --git a/cmd/main.go b/cmd/main.go index f3c3d97..a20812a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -18,7 +18,7 @@ func main() { plugin.MustNewPluginLoader(ctx, 8081, plugin.NewPluginStore()). Run(ctx) - pg := postgres.New(ctx, "postgres://draincloud:draincloud-rw-password-123@192.168.1.68:5433/draincloud?sslmode=disable") + pg := postgres.New(ctx, "postgres://draincloud:mysuperstrongpassword@127.0.0.1:5432/draincloud?sslmode=disable") // TODO move cron on a separate job (k8s cronjob / docker cron) // cleanupSessionsCron := cleanupsessions.New(pg) diff --git a/compose.rw.yaml b/compose.rw.yaml index f88c9a8..8e2aa22 100644 --- a/compose.rw.yaml +++ b/compose.rw.yaml @@ -13,38 +13,38 @@ services: networks: - draincloud-pg - rw_2: - image: postgres:17 - container_name: draincloud-db-rw-2 - ports: - - 5433:5432 - environment: - - POSTGRES_USER=draincloud - - POSTGRES_PASSWORD=mysuperstrongpassword - - POSTGRES_DB=draincloud - volumes: - - draincloud-rw-2:/var/lib/postgresql/data - networks: - - draincloud-pg + # rw_2: + # image: postgres:17 + # container_name: draincloud-db-rw-2 + # ports: + # - 5433:5432 + # environment: + # - POSTGRES_USER=draincloud + # - POSTGRES_PASSWORD=mysuperstrongpassword + # - POSTGRES_DB=draincloud + # volumes: + # - draincloud-rw-2:/var/lib/postgresql/data + # networks: + # - draincloud-pg - rw_3: - image: postgres:17 - container_name: draincloud-db-rw-3 - ports: - - 5434:5432 - environment: - - POSTGRES_USER=draincloud - - POSTGRES_PASSWORD=mysuperstrongpassword - - POSTGRES_DB=draincloud - volumes: - - draincloud-rw-3:/var/lib/postgresql/data - networks: - - draincloud-pg + # rw_3: + # image: postgres:17 + # container_name: draincloud-db-rw-3 + # ports: + # - 5434:5432 + # environment: + # - POSTGRES_USER=draincloud + # - POSTGRES_PASSWORD=mysuperstrongpassword + # - POSTGRES_DB=draincloud + # volumes: + # - draincloud-rw-3:/var/lib/postgresql/data + # networks: + # - draincloud-pg volumes: draincloud-rw-1: {} - draincloud-rw-2: {} - draincloud-rw-3: {} + # draincloud-rw-2: {} + # draincloud-rw-3: {} networks: draincloud-pg: {} \ No newline at end of file diff --git a/internal/common/request.go b/internal/common/request.go new file mode 100644 index 0000000..c3548ef --- /dev/null +++ b/internal/common/request.go @@ -0,0 +1,63 @@ +package common + +import ( + "fmt" + "net/http" + "sync" + + "git.optclblast.xyz/draincloud/draincloud-core/internal/storage/models" + "github.com/google/uuid" +) + +type RequestPool struct { + sp sync.Pool +} + +type Request struct { + ID string + Session *models.Session + User *models.User + ResolveValues sync.Map + Metadata map[string]any + Body []byte +} + +// NewRequest builds a new *Request struct from raw http Request. No auth data validated. +func NewRequest(pool *RequestPool, req *http.Request) *Request { + out := pool.sp.Get().(*Request) + + cookies := req.Cookies() + headers := req.Header + + out.Metadata = make(map[string]any, len(cookies)) + + for _, cookie := range cookies { + out.Metadata[cookie.Name] = cookie.Value + } + + for hname, hval := range headers { + out.Metadata[hname] = hval + } + + reqID := uuid.NewString() + out.ID = reqID + return out +} + +func GetValue[T any](vals map[string]any, key string) (T, error) { + var out T + if vals == nil { + return out, fmt.Errorf("nil vals map") + } + rawVal, ok := vals[key] + if !ok { + return out, fmt.Errorf("value not found in resolve values set") + } + + out, ok = rawVal.(T) + if !ok { + return out, fmt.Errorf("type of a value is unexpected") + } + + return out, nil +} diff --git a/internal/common/request_test.go b/internal/common/request_test.go new file mode 100644 index 0000000..9898dd3 --- /dev/null +++ b/internal/common/request_test.go @@ -0,0 +1,250 @@ +package common + +import ( + "reflect" + "testing" +) + +func TestGetValue_string(t *testing.T) { + t.Parallel() + type args struct { + vals map[string]any + key string + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "ok", + args: args{ + vals: map[string]any{ + "1": "123", + "2": "234", + }, + key: "1", + }, + want: "123", + wantErr: false, + }, + { + name: "value not presented", + args: args{ + vals: map[string]any{ + "1": "123", + "2": "234", + }, + key: "3", + }, + want: "", + wantErr: true, + }, + { + name: "nil map", + args: args{ + vals: nil, + key: "1", + }, + want: "", + wantErr: true, + }, + { + name: "invalid type", + args: args{ + vals: map[string]any{ + "1": "123", + "2": 234, + }, + key: "2", + }, + want: "", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GetValue[string](tt.args.vals, tt.args.key) + if (err != nil) != tt.wantErr { + t.Errorf("GetValue() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GetValue() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetValue_struct(t *testing.T) { + t.Parallel() + + type val struct { + a int + b string + c bool + } + type args struct { + vals map[string]any + key string + } + tests := []struct { + name string + args args + want val + wantErr bool + }{ + { + name: "ok", + args: args{ + vals: map[string]any{ + "1": val{ + a: 1, + b: "2", + c: true, + }, + "2": "234", + }, + key: "1", + }, + want: val{ + a: 1, + b: "2", + c: true, + }, + wantErr: false, + }, + { + name: "value not presented", + args: args{ + vals: map[string]any{ + "1": "123", + "2": "234", + }, + key: "3", + }, + want: val{}, + wantErr: true, + }, + { + name: "nil map", + args: args{ + vals: nil, + key: "1", + }, + want: val{}, + wantErr: true, + }, + { + name: "invalid type", + args: args{ + vals: map[string]any{ + "1": "123", + "2": 234, + }, + key: "2", + }, + want: val{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GetValue[val](tt.args.vals, tt.args.key) + if (err != nil) != tt.wantErr { + t.Errorf("GetValue() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GetValue() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetValue_structptr(t *testing.T) { + t.Parallel() + + type val struct { + a int + b string + c bool + } + type args struct { + vals map[string]any + key string + } + tests := []struct { + name string + args args + want *val + wantErr bool + }{ + { + name: "ok", + args: args{ + vals: map[string]any{ + "1": &val{ + a: 1, + b: "2", + c: true, + }, + "2": "234", + }, + key: "1", + }, + want: &val{ + a: 1, + b: "2", + c: true, + }, + wantErr: false, + }, + { + name: "value not presented", + args: args{ + vals: map[string]any{ + "1": "123", + "2": "234", + }, + key: "3", + }, + want: nil, + wantErr: true, + }, + { + name: "nil map", + args: args{ + vals: nil, + key: "1", + }, + want: nil, + wantErr: true, + }, + { + name: "invalid type", + args: args{ + vals: map[string]any{ + "1": "123", + "2": 234, + }, + key: "2", + }, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GetValue[*val](tt.args.vals, tt.args.key) + if (err != nil) != tt.wantErr { + t.Errorf("GetValue() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GetValue() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/errs/errors.go b/internal/errs/errors.go new file mode 100644 index 0000000..cc396b6 --- /dev/null +++ b/internal/errs/errors.go @@ -0,0 +1,9 @@ +package errs + +import "errors" + +var ( + ErrorUnauthorized = errors.New("unauthorized") + ErrorAccessDenied = errors.New("access denied") + ErrorSessionExpired = errors.New("session expired") +) diff --git a/internal/handler/handler.go b/internal/handler/handler.go new file mode 100644 index 0000000..6edeb1c --- /dev/null +++ b/internal/handler/handler.go @@ -0,0 +1,17 @@ +package handler + +import ( + "context" + + "git.optclblast.xyz/draincloud/draincloud-core/internal/common" +) + +type Writer interface { + Write(ctx context.Context, resp any) +} + +type Handler struct { + Name string + RequiredResolveParams []string + ProcessFn func(ctx context.Context, req *common.Request, w Writer) error +} diff --git a/internal/jetstream/js.go b/internal/jetstream/js.go deleted file mode 100644 index ad33b63..0000000 --- a/internal/jetstream/js.go +++ /dev/null @@ -1 +0,0 @@ -package jetstream diff --git a/internal/processor/processor.go b/internal/processor/processor.go new file mode 100644 index 0000000..6fbcc66 --- /dev/null +++ b/internal/processor/processor.go @@ -0,0 +1,51 @@ +package processor + +import ( + "errors" + "net/http" + + "git.optclblast.xyz/draincloud/draincloud-core/internal/common" + "git.optclblast.xyz/draincloud/draincloud-core/internal/domain" + "git.optclblast.xyz/draincloud/draincloud-core/internal/errs" + "git.optclblast.xyz/draincloud/draincloud-core/internal/handler" + "git.optclblast.xyz/draincloud/draincloud-core/internal/storage" + "github.com/gin-gonic/gin" +) + +type Processor struct { + rp *common.RequestPool + authStorage storage.AuthStorage +} + +func (p *Processor) Process(handler *handler.Handler) gin.HandlerFunc { + return func(ctx *gin.Context) { + //req := common.NewRequest(p.rp, ctx.Request) + // if handler.WithAuth { + // if err := p.authorize(ctx, req); err != nil { + // p.writeError(ctx, err) + // return + // } + // } + + } +} + +func (p *Processor) writeError(ctx *gin.Context, err error) { + switch { + case errors.Is(err, errs.ErrorAccessDenied): + ctx.JSON(http.StatusInternalServerError, domain.ErrorJson{ + Code: http.StatusForbidden, + Message: err.Error(), + }) + case errors.Is(err, errs.ErrorSessionExpired): + ctx.JSON(http.StatusInternalServerError, domain.ErrorJson{ + Code: http.StatusForbidden, + Message: err.Error(), + }) + default: + ctx.JSON(http.StatusInternalServerError, domain.ErrorJson{ + Code: http.StatusInternalServerError, + Message: "Internal Error", + }) + } +} diff --git a/internal/resolvers/auth/auth_resolver.go b/internal/resolvers/auth/auth_resolver.go new file mode 100644 index 0000000..93b1a31 --- /dev/null +++ b/internal/resolvers/auth/auth_resolver.go @@ -0,0 +1,95 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "time" + + "git.optclblast.xyz/draincloud/draincloud-core/internal/common" + "git.optclblast.xyz/draincloud/draincloud-core/internal/errs" + "git.optclblast.xyz/draincloud/draincloud-core/internal/logger" + "git.optclblast.xyz/draincloud/draincloud-core/internal/storage" + "git.optclblast.xyz/draincloud/draincloud-core/internal/storage/models" +) + +const ( + csrfTokenCookie = "__Csrf_token" + sessionTokenCookie = "__Session_token" +) + +type AuthResolver struct { + authStorage storage.AuthStorage +} + +func (r *AuthResolver) Resolve(ctx context.Context, req *common.Request) error { + return r.authorize(ctx, req) +} + +func (p *AuthResolver) authorize(ctx context.Context, req *common.Request) error { + session, err := p.getSession(ctx, req) + if err != nil && !errors.Is(err, http.ErrNoCookie) { + return errs.ErrorUnauthorized + } + + if session == nil { + return errs.ErrorUnauthorized + } + + if err := validateSession(ctx, req, session); err != nil { + // TODO add audit log entry + return errs.ErrorUnauthorized + } + + user, err := p.authStorage.GetUserByID(ctx, session.UserID) + if err != nil { + return fmt.Errorf("failed to fetch user by id: %w", err) + } + logger.Debug(ctx, "[authorize] user authorized", slog.String("session_id", session.ID.String())) + + req.User = user + req.Session = session + + return nil +} + +func (d *AuthResolver) getSession(ctx context.Context, req *common.Request) (*models.Session, error) { + token, err := common.GetValue[string](req.Metadata, sessionTokenCookie) + if err != nil { + return nil, fmt.Errorf("failed to fetch session cookie from request: %w", err) + } + + if len(token) == 0 { + return nil, fmt.Errorf("session token or csrf token is empty") + } + + session, err := d.authStorage.GetSession(ctx, token) + if err != nil { + return nil, fmt.Errorf("failed to fetch session from repo: %w", err) + } + + return session, nil +} + +func validateSession(ctx context.Context, req *common.Request, session *models.Session) error { + if session == nil { + return errs.ErrorAccessDenied + } + + csrfToken, err := common.GetValue[string](req.Metadata, csrfTokenCookie) + if err != nil { + return fmt.Errorf("failed to fetch csrf cookie from request: %w", err) + } + + if session.CsrfToken != csrfToken { + return errs.ErrorAccessDenied + } + + if session.ExpiredAt.Before(time.Now()) { + return errs.ErrorSessionExpired + } + + return nil +} diff --git a/internal/resolvers/resolver.go b/internal/resolvers/resolver.go new file mode 100644 index 0000000..7994d8d --- /dev/null +++ b/internal/resolvers/resolver.go @@ -0,0 +1,11 @@ +package resolvers + +import ( + "context" + + "git.optclblast.xyz/draincloud/draincloud-core/internal/common" +) + +type Resolver interface { + Resolve(ctx context.Context, req *common.Request) error +}