Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 60 additions & 2 deletions api/pkg/controller/handlers.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package controller

import (
"context"
"errors"

"github.com/lukemarsden/helix/api/pkg/store"
"github.com/lukemarsden/helix/api/pkg/types"
)

func (c *Controller) GetStatus(ctx types.RequestContext) (types.UserStatus, error) {
balanceTransfers, err := c.Options.Store.GetBalanceTransfers(ctx.Ctx, store.GetBalanceTransfersQuery{
balanceTransfers, err := c.Options.Store.GetBalanceTransfers(ctx.Ctx, store.OwnerQuery{
Owner: ctx.Owner,
OwnerType: ctx.OwnerType,
})
Expand All @@ -26,8 +29,63 @@ func (c *Controller) GetStatus(ctx types.RequestContext) (types.UserStatus, erro
}

func (c *Controller) GetTransactions(ctx types.RequestContext) ([]*types.BalanceTransfer, error) {
return c.Options.Store.GetBalanceTransfers(ctx.Ctx, store.GetBalanceTransfersQuery{
return c.Options.Store.GetBalanceTransfers(ctx.Ctx, store.OwnerQuery{
Owner: ctx.Owner,
OwnerType: ctx.OwnerType,
})
}
func (c *Controller) CreateAPIKey(ctx types.RequestContext, name string) (string, error) {
apiKey, err := c.Options.Store.CreateAPIKey(ctx.Ctx, store.OwnerQuery{
Owner: ctx.Owner,
OwnerType: ctx.OwnerType,
}, name)
if err != nil {
return "", err
}
return apiKey, nil
}

func (c *Controller) GetAPIKeys(ctx types.RequestContext) ([]*types.ApiKey, error) {
apiKeys, err := c.Options.Store.GetAPIKeys(ctx.Ctx, store.OwnerQuery{
Owner: ctx.Owner,
OwnerType: ctx.OwnerType,
})
if err != nil {
return nil, err
}
if apiKeys == nil {
_, err := c.CreateAPIKey(ctx, "default")
if err != nil {
return nil, err
}
return c.GetAPIKeys(ctx)
}
return apiKeys, nil
}

func (c *Controller) DeleteAPIKey(ctx types.RequestContext, apiKey string) error {
fetchedApiKey, err := c.Options.Store.CheckAPIKey(ctx.Ctx, apiKey)
if err != nil {
return err
}
if fetchedApiKey == nil {
return errors.New("no such key")
}
// only the owner of an api key can delete it
if fetchedApiKey.Owner != ctx.Owner || fetchedApiKey.OwnerType != ctx.OwnerType {
return errors.New("unauthorized")
}
err = c.Options.Store.DeleteAPIKey(ctx.Ctx, *fetchedApiKey)
if err != nil {
return err
}
return nil
}

func (c *Controller) CheckAPIKey(ctx context.Context, apiKey string) (*types.ApiKey, error) {
key, err := c.Options.Store.CheckAPIKey(ctx, apiKey)
if err != nil {
return nil, err
}
return key, nil
}
35 changes: 35 additions & 0 deletions api/pkg/server/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -520,3 +520,38 @@ func (apiServer *HelixAPIServer) respondRunnerSession(res http.ResponseWriter, r
}
return taskResponse, nil
}

func (apiServer *HelixAPIServer) createAPIKey(res http.ResponseWriter, req *http.Request) (string, error) {
name := req.URL.Query().Get("name")
apiKey, err := apiServer.Controller.CreateAPIKey(apiServer.getRequestContext(req), name)
if err != nil {
return "", err
}
return apiKey, nil
}

func (apiServer *HelixAPIServer) getAPIKeys(res http.ResponseWriter, req *http.Request) ([]*types.ApiKey, error) {
apiKeys, err := apiServer.Controller.GetAPIKeys(apiServer.getRequestContext(req))
if err != nil {
return nil, err
}
return apiKeys, nil
}

func (apiServer *HelixAPIServer) deleteAPIKey(res http.ResponseWriter, req *http.Request) (string, error) {
apiKey := req.URL.Query().Get("key")
err := apiServer.Controller.DeleteAPIKey(apiServer.getRequestContext(req), apiKey)
if err != nil {
return "", err
}
return "", nil
}

func (apiServer *HelixAPIServer) checkAPIKey(res http.ResponseWriter, req *http.Request) (*types.ApiKey, error) {
apiKey := req.URL.Query().Get("key")
key, err := apiServer.Controller.CheckAPIKey(apiServer.getRequestContext(req).Ctx, apiKey)
if err != nil {
return nil, err
}
return key, nil
}
43 changes: 39 additions & 4 deletions api/pkg/server/keycloak.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (

gocloak "github.com/Nerzal/gocloak/v13"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/lukemarsden/helix/api/pkg/store"
"github.com/lukemarsden/helix/api/pkg/types"
)

const CLIENT_ID = "api"
Expand Down Expand Up @@ -36,16 +38,37 @@ func newKeycloak(options ServerOptions) *keycloak {
type keyCloakMiddleware struct {
keycloak *keycloak
options ServerOptions
store store.Store
}

func newMiddleware(keycloak *keycloak, options ServerOptions) *keyCloakMiddleware {
return &keyCloakMiddleware{keycloak: keycloak, options: options}
func newMiddleware(keycloak *keycloak, options ServerOptions, store store.Store) *keyCloakMiddleware {
return &keyCloakMiddleware{keycloak: keycloak, options: options, store: store}
}

func extractBearerToken(token string) string {
return strings.Replace(token, "Bearer ", "", 1)
}

func (auth *keyCloakMiddleware) maybeOwnerFromRequest(r *http.Request) (*types.ApiKey, error) {
// in case the request is authenticated with an lp- token, rather than a
// keycloak JWT, return the owner. Returns nil if it's not an lp- token.
token := r.Header.Get("Authorization")
token = extractBearerToken(token)

if strings.HasPrefix(token, "lp-") {
if owner, err := auth.store.CheckAPIKey(r.Context(), token); err != nil {
return nil, fmt.Errorf("error checking API key: %s", err.Error())
} else if owner == nil {
// user claimed to provide lp- token, but it was invalid
return nil, fmt.Errorf("invalid API key")
} else {
return owner, nil
}
}
// user didn't claim token was an lp token, so fallback to keycloak
return nil, nil
}

func (auth *keyCloakMiddleware) jwtFromRequest(r *http.Request) (*jwt.Token, error) {
// try to extract Authorization parameter from the HTTP header
token := r.Header.Get("Authorization")
Expand Down Expand Up @@ -107,12 +130,24 @@ func getRequestUser(req *http.Request) string {
func (auth *keyCloakMiddleware) verifyToken(next http.Handler) http.Handler {

f := func(w http.ResponseWriter, r *http.Request) {
token, err := auth.jwtFromRequest(r)
maybeOwner, err := auth.maybeOwnerFromRequest(r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
r = r.WithContext(setRequestUser(r.Context(), getUserIdFromJWT(token)))
if maybeOwner == nil {
// check keycloak JWT
token, err := auth.jwtFromRequest(r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
r = r.WithContext(setRequestUser(r.Context(), getUserIdFromJWT(token)))
next.ServeHTTP(w, r)
return
}
// successful api_key auth
r = r.WithContext(setRequestUser(r.Context(), maybeOwner.Owner))
next.ServeHTTP(w, r)
}

Expand Down
7 changes: 6 additions & 1 deletion api/pkg/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func (apiServer *HelixAPIServer) ListenAndServe(ctx context.Context, cm *system.
}).Subrouter()

keycloak := newKeycloak(apiServer.Options)
keyCloakMiddleware := newMiddleware(keycloak, apiServer.Options)
keyCloakMiddleware := newMiddleware(keycloak, apiServer.Options, apiServer.Store)
authRouter.Use(keyCloakMiddleware.verifyToken)

authRouter.HandleFunc("/status", Wrapper(apiServer.status)).Methods("GET")
Expand All @@ -87,6 +87,11 @@ func (apiServer *HelixAPIServer) ListenAndServe(ctx context.Context, cm *system.
authRouter.HandleFunc("/filestore/rename", Wrapper(apiServer.filestoreRename)).Methods("PUT")
authRouter.HandleFunc("/filestore/delete", Wrapper(apiServer.filestoreDelete)).Methods("DELETE")

authRouter.HandleFunc("/api_keys", Wrapper(apiServer.createAPIKey)).Methods("POST")
authRouter.HandleFunc("/api_keys", Wrapper(apiServer.getAPIKeys)).Methods("GET")
authRouter.HandleFunc("/api_keys", Wrapper(apiServer.deleteAPIKey)).Methods("DELETE")
authRouter.HandleFunc("/api_keys/check", Wrapper(apiServer.checkAPIKey)).Methods("GET")

if apiServer.Options.LocalFilestorePath != "" {
fileServer := http.FileServer(http.Dir(apiServer.Options.LocalFilestorePath))
subrouter.PathPrefix("/filestore/viewer/").Handler(http.StripPrefix("/api/v1/filestore/viewer/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
1 change: 1 addition & 0 deletions api/pkg/store/migrations/0003_api_key.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
drop table api_key;
7 changes: 7 additions & 0 deletions api/pkg/store/migrations/0003_api_key.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- TODO: add created_at
create table api_key (
owner varchar(255) NOT NULL,
owner_type varchar(255) NOT NULL,
key varchar(255) PRIMARY KEY,
name varchar(255) NOT NULL
);
125 changes: 124 additions & 1 deletion api/pkg/store/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package store

import (
"context"
"crypto/rand"
"embed"
"encoding/base64"
"encoding/json"
"fmt"

Expand Down Expand Up @@ -244,7 +246,7 @@ func (d *PostgresStore) UpdateSession(

func (d *PostgresStore) GetBalanceTransfers(
ctx context.Context,
query GetBalanceTransfersQuery,
query OwnerQuery,
) ([]*types.BalanceTransfer, error) {
d.mtx.RLock()
defer d.mtx.RUnlock()
Expand Down Expand Up @@ -346,6 +348,127 @@ values ($1, $2, $3, $4, $5, $6)`
return nil
}

func (d *PostgresStore) CreateAPIKey(ctx context.Context, owner OwnerQuery, name string) (string, error) {
d.mtx.Lock()
defer d.mtx.Unlock()

// Generate a new API key
key, err := generateAPIKey()
if err != nil {
return "", err
}

// Insert the new API key into the database
sqlStatement := `
insert into api_key (owner, owner_type, key, name)
values ($1, $2, $3, $4)
returning key
`
var id string
err = d.db.QueryRow(
sqlStatement,
owner.Owner,
owner.OwnerType,
key,
name,
).Scan(&id)
if err != nil {
return "", err
}

return id, nil
}

func generateAPIKey() (string, error) {
key := make([]byte, 32)
_, err := rand.Read(key)
if err != nil {
return "", err
}
return "lp-" + base64.URLEncoding.EncodeToString(key), nil
}

func (d *PostgresStore) GetAPIKeys(ctx context.Context, query OwnerQuery) ([]*types.ApiKey, error) {
d.mtx.RLock()
defer d.mtx.RUnlock()
var apiKeys []*types.ApiKey
sqlStatement := `
select
key,
owner,
owner_type
from
api_key
where
owner = $1 and owner_type = $2
`
rows, err := d.db.Query(
sqlStatement,
query.Owner,
query.OwnerType,
)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var apiKey types.ApiKey
err := rows.Scan(
&apiKey.Key,
&apiKey.Owner,
&apiKey.OwnerType,
)
if err != nil {
return nil, err
}
apiKeys = append(apiKeys, &apiKey)
}
err = rows.Err()
if err != nil {
return nil, err
}
return apiKeys, nil
}

func (d *PostgresStore) DeleteAPIKey(ctx context.Context, apiKey types.ApiKey) error {
d.mtx.Lock()
defer d.mtx.Unlock()
sqlStatement := `
delete from api_key where key = $1 and owner = $2 and owner_type = $3
`
_, err := d.db.Exec(
sqlStatement,
apiKey.Key,
apiKey.Owner,
apiKey.OwnerType,
)
return err
}

func (d *PostgresStore) CheckAPIKey(ctx context.Context, apiKey string) (*types.ApiKey, error) {
d.mtx.RLock()
defer d.mtx.RUnlock()
var key types.ApiKey
sqlStatement := `
select
key, owner, owner_type
from
api_key
where
key = $1
`
row := d.db.QueryRow(sqlStatement, apiKey)
err := row.Scan(&key.Key, &key.Owner, &key.OwnerType)
if err != nil {
if err == sql.ErrNoRows {
// not an error, but not a valid api key either
return nil, nil
}
return nil, err
}
return &key, nil
}

// Compile-time interface check:
var _ Store = (*PostgresStore)(nil)

Expand Down
Loading