4 Commits

Author SHA1 Message Date
clawbot
e73409b567 fix: resolve lint issues for make check compliance 2026-02-19 23:43:41 -08:00
clawbot
a891fb2489 fix: increase API token entropy from 128 to 256 bits
Change token random bytes from 16 to 32, producing tokens with
upaas_ prefix + 64 hex characters instead of 32.
2026-02-19 23:43:22 -08:00
clawbot
96eea71c54 fix: set authenticated user on request context in bearer token auth
tryBearerAuth validated the bearer token but never looked up the
associated user or set it on the request context. This meant
downstream handlers calling GetCurrentUser would get nil even
with a valid token.

Changes:
- Add ContextWithUser/UserFromContext helpers in auth package
- tryBearerAuth now looks up the user by token's UserID and
  sets it on the request context via auth.ContextWithUser
- GetCurrentUser checks context first before falling back to
  session cookie
- Add integration tests for bearer auth user context
2026-02-19 23:43:22 -08:00
clawbot
7387ba6b5c feat: add API token authentication (closes #87)
- Add api_tokens table migration (007)
- Add APIToken model with CRUD operations
- Generate tokens with upaas_ prefix + 32 hex chars
- Store SHA-256 hash of tokens (not plaintext)
- Update APISessionAuth middleware to check Bearer tokens
- Add POST/GET/DELETE /api/v1/tokens endpoints
- Token creation returns plaintext once; list never exposes it
- Expired and revoked tokens are rejected
- Tests for creation, listing, deletion, bearer auth, revocation
2026-02-19 23:43:22 -08:00
22 changed files with 1249 additions and 419 deletions

View File

@@ -1,26 +0,0 @@
name: Check
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
- name: Install golangci-lint
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee # v2.10.1
- name: Install goimports
run: go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0 # v0.42.0
- name: Run make check
run: make check

View File

@@ -51,7 +51,7 @@ type Config struct {
MaintenanceMode bool MaintenanceMode bool
MetricsUsername string MetricsUsername string
MetricsPassword string MetricsPassword string
SessionSecret string `json:"-"` SessionSecret string //nolint:gosec // not a hardcoded credential, loaded from env/file
CORSOrigins string CORSOrigins string
params *Params params *Params
log *slog.Logger log *slog.Logger

View File

@@ -176,6 +176,13 @@ func HashWebhookSecret(secret string) string {
return hex.EncodeToString(sum[:]) return hex.EncodeToString(sum[:])
} }
// HashAPIToken returns the hex-encoded SHA-256 hash of an API token.
func HashAPIToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func (d *Database) backfillWebhookSecretHashes(ctx context.Context) error { func (d *Database) backfillWebhookSecretHashes(ctx context.Context) error {
rows, err := d.database.QueryContext(ctx, rows, err := d.database.QueryContext(ctx,
"SELECT id, webhook_secret FROM apps WHERE webhook_secret_hash = '' AND webhook_secret != ''") "SELECT id, webhook_secret FROM apps WHERE webhook_secret_hash = '' AND webhook_secret != ''")

View File

@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
token_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME,
last_used_at DATETIME
);
CREATE INDEX idx_api_tokens_user_id ON api_tokens(user_id);
CREATE INDEX idx_api_tokens_token_hash ON api_tokens(token_hash);

View File

@@ -8,6 +8,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"git.eeqj.de/sneak/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/app"
) )
// apiAppResponse is the JSON representation of an app. // apiAppResponse is the JSON representation of an app.
@@ -73,13 +74,18 @@ func deploymentToAPI(d *models.Deployment) apiDeploymentResponse {
// HandleAPILoginPOST returns a handler that authenticates via JSON credentials // HandleAPILoginPOST returns a handler that authenticates via JSON credentials
// and sets a session cookie. // and sets a session cookie.
func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc { func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"` //nolint:gosec // request field, not a hardcoded credential
}
type loginResponse struct { type loginResponse struct {
UserID int64 `json:"userId"` UserID int64 `json:"userId"`
Username string `json:"username"` Username string `json:"username"`
} }
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
var req map[string]string var req loginRequest
decodeErr := json.NewDecoder(request.Body).Decode(&req) decodeErr := json.NewDecoder(request.Body).Decode(&req)
if decodeErr != nil { if decodeErr != nil {
@@ -90,10 +96,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
return return
} }
username := req["username"] if req.Username == "" || req.Password == "" {
credential := req["password"]
if username == "" || credential == "" {
h.respondJSON(writer, request, h.respondJSON(writer, request,
map[string]string{"error": "username and password are required"}, map[string]string{"error": "username and password are required"},
http.StatusBadRequest) http.StatusBadRequest)
@@ -101,7 +104,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc {
return return
} }
user, authErr := h.auth.Authenticate(request.Context(), username, credential) user, authErr := h.auth.Authenticate(request.Context(), req.Username, req.Password)
if authErr != nil { if authErr != nil {
h.respondJSON(writer, request, h.respondJSON(writer, request,
map[string]string{"error": "invalid credentials"}, map[string]string{"error": "invalid credentials"},
@@ -174,6 +177,106 @@ func (h *Handlers) HandleAPIGetApp() http.HandlerFunc {
} }
} }
// HandleAPICreateApp returns a handler that creates a new app.
func (h *Handlers) HandleAPICreateApp() http.HandlerFunc {
type createRequest struct {
Name string `json:"name"`
RepoURL string `json:"repoUrl"`
Branch string `json:"branch"`
DockerfilePath string `json:"dockerfilePath"`
DockerNetwork string `json:"dockerNetwork"`
NtfyTopic string `json:"ntfyTopic"`
SlackWebhook string `json:"slackWebhook"`
}
return func(writer http.ResponseWriter, request *http.Request) {
var req createRequest
decodeErr := json.NewDecoder(request.Body).Decode(&req)
if decodeErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "invalid JSON body"},
http.StatusBadRequest)
return
}
if req.Name == "" || req.RepoURL == "" {
h.respondJSON(writer, request,
map[string]string{"error": "name and repo_url are required"},
http.StatusBadRequest)
return
}
nameErr := validateAppName(req.Name)
if nameErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "invalid app name: " + nameErr.Error()},
http.StatusBadRequest)
return
}
createdApp, createErr := h.appService.CreateApp(request.Context(), app.CreateAppInput{
Name: req.Name,
RepoURL: req.RepoURL,
Branch: req.Branch,
DockerfilePath: req.DockerfilePath,
DockerNetwork: req.DockerNetwork,
NtfyTopic: req.NtfyTopic,
SlackWebhook: req.SlackWebhook,
})
if createErr != nil {
h.log.Error("api: failed to create app", "error", createErr)
h.respondJSON(writer, request,
map[string]string{"error": "failed to create app"},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request, appToAPI(createdApp), http.StatusCreated)
}
}
// HandleAPIDeleteApp returns a handler that deletes an app.
func (h *Handlers) HandleAPIDeleteApp() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, err := h.appService.GetApp(request.Context(), appID)
if err != nil {
h.respondJSON(writer, request,
map[string]string{"error": "internal server error"},
http.StatusInternalServerError)
return
}
if application == nil {
h.respondJSON(writer, request,
map[string]string{"error": "app not found"},
http.StatusNotFound)
return
}
deleteErr := h.appService.DeleteApp(request.Context(), application)
if deleteErr != nil {
h.log.Error("api: failed to delete app", "error", deleteErr)
h.respondJSON(writer, request,
map[string]string{"error": "failed to delete app"},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request,
map[string]string{"status": "deleted"}, http.StatusOK)
}
}
// deploymentsPageLimit is the default number of deployments per page. // deploymentsPageLimit is the default number of deployments per page.
const deploymentsPageLimit = 20 const deploymentsPageLimit = 20
@@ -220,6 +323,35 @@ func (h *Handlers) HandleAPIListDeployments() http.HandlerFunc {
} }
} }
// HandleAPITriggerDeploy returns a handler that triggers a deployment for an app.
func (h *Handlers) HandleAPITriggerDeploy() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, err := h.appService.GetApp(request.Context(), appID)
if err != nil || application == nil {
h.respondJSON(writer, request,
map[string]string{"error": "app not found"},
http.StatusNotFound)
return
}
deployErr := h.deploy.Deploy(request.Context(), application, nil, true)
if deployErr != nil {
h.log.Error("api: failed to trigger deploy", "error", deployErr)
h.respondJSON(writer, request,
map[string]string{"error": deployErr.Error()},
http.StatusConflict)
return
}
h.respondJSON(writer, request,
map[string]string{"status": "deploying"}, http.StatusAccepted)
}
}
// HandleAPIWhoAmI returns a handler that shows the current authenticated user. // HandleAPIWhoAmI returns a handler that shows the current authenticated user.
func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc { func (h *Handlers) HandleAPIWhoAmI() http.HandlerFunc {
type whoAmIResponse struct { type whoAmIResponse struct {

View File

@@ -10,8 +10,6 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"git.eeqj.de/sneak/upaas/internal/service/app"
) )
// apiRouter builds a chi router with the API routes using session auth middleware. // apiRouter builds a chi router with the API routes using session auth middleware.
@@ -25,7 +23,10 @@ func apiRouter(tc *testContext) http.Handler {
apiR.Use(tc.middleware.APISessionAuth()) apiR.Use(tc.middleware.APISessionAuth())
apiR.Get("/whoami", tc.handlers.HandleAPIWhoAmI()) apiR.Get("/whoami", tc.handlers.HandleAPIWhoAmI())
apiR.Get("/apps", tc.handlers.HandleAPIListApps()) apiR.Get("/apps", tc.handlers.HandleAPIListApps())
apiR.Post("/apps", tc.handlers.HandleAPICreateApp())
apiR.Get("/apps/{id}", tc.handlers.HandleAPIGetApp()) apiR.Get("/apps/{id}", tc.handlers.HandleAPIGetApp())
apiR.Delete("/apps/{id}", tc.handlers.HandleAPIDeleteApp())
apiR.Post("/apps/{id}/deploy", tc.handlers.HandleAPITriggerDeploy())
apiR.Get("/apps/{id}/deployments", tc.handlers.HandleAPIListDeployments()) apiR.Get("/apps/{id}/deployments", tc.handlers.HandleAPIListDeployments())
}) })
}) })
@@ -61,16 +62,23 @@ func setupAPITest(t *testing.T) (*testContext, []*http.Cookie) {
return tc, cookies return tc, cookies
} }
// apiGet makes an authenticated GET request using session cookies. // apiRequest makes an authenticated API request using session cookies.
func apiGet( func apiRequest(
t *testing.T, t *testing.T,
tc *testContext, tc *testContext,
cookies []*http.Cookie, cookies []*http.Cookie,
path string, method, path string,
body string,
) *httptest.ResponseRecorder { ) *httptest.ResponseRecorder {
t.Helper() t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil) var req *http.Request
if body != "" {
req = httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
for _, c := range cookies { for _, c := range cookies {
req.AddCookie(c) req.AddCookie(c)
@@ -167,7 +175,7 @@ func TestAPIWhoAmI(t *testing.T) {
tc, cookies := setupAPITest(t) tc, cookies := setupAPITest(t)
rr := apiGet(t, tc, cookies, "/api/v1/whoami") rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/whoami", "")
assert.Equal(t, http.StatusOK, rr.Code) assert.Equal(t, http.StatusOK, rr.Code)
var resp map[string]any var resp map[string]any
@@ -180,7 +188,7 @@ func TestAPIListAppsEmpty(t *testing.T) {
tc, cookies := setupAPITest(t) tc, cookies := setupAPITest(t)
rr := apiGet(t, tc, cookies, "/api/v1/apps") rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps", "")
assert.Equal(t, http.StatusOK, rr.Code) assert.Equal(t, http.StatusOK, rr.Code)
var apps []any var apps []any
@@ -188,23 +196,52 @@ func TestAPIListAppsEmpty(t *testing.T) {
assert.Empty(t, apps) assert.Empty(t, apps)
} }
func TestAPICreateApp(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
body := `{"name":"test-app","repoUrl":"https://github.com/example/repo"}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
assert.Equal(t, http.StatusCreated, rr.Code)
var app map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &app))
assert.Equal(t, "test-app", app["name"])
assert.Equal(t, "pending", app["status"])
}
func TestAPICreateAppValidation(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
body := `{"name":"","repoUrl":""}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestAPIGetApp(t *testing.T) { func TestAPIGetApp(t *testing.T) {
t.Parallel() t.Parallel()
tc, cookies := setupAPITest(t) tc, cookies := setupAPITest(t)
created, err := tc.appSvc.CreateApp(t.Context(), app.CreateAppInput{ body := `{"name":"my-app","repoUrl":"https://github.com/example/repo"}`
Name: "my-app", rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
RepoURL: "https://github.com/example/repo", require.Equal(t, http.StatusCreated, rr.Code)
})
require.NoError(t, err)
rr := apiGet(t, tc, cookies, "/api/v1/apps/"+created.ID) var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
appID, ok := created["id"].(string)
require.True(t, ok)
rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID, "")
assert.Equal(t, http.StatusOK, rr.Code) assert.Equal(t, http.StatusOK, rr.Code)
var resp map[string]any var app map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &app))
assert.Equal(t, "my-app", resp["name"]) assert.Equal(t, "my-app", app["name"])
} }
func TestAPIGetAppNotFound(t *testing.T) { func TestAPIGetAppNotFound(t *testing.T) {
@@ -212,7 +249,29 @@ func TestAPIGetAppNotFound(t *testing.T) {
tc, cookies := setupAPITest(t) tc, cookies := setupAPITest(t)
rr := apiGet(t, tc, cookies, "/api/v1/apps/nonexistent") rr := apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/nonexistent", "")
assert.Equal(t, http.StatusNotFound, rr.Code)
}
func TestAPIDeleteApp(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
body := `{"name":"delete-me","repoUrl":"https://github.com/example/repo"}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
require.Equal(t, http.StatusCreated, rr.Code)
var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
appID, ok := created["id"].(string)
require.True(t, ok)
rr = apiRequest(t, tc, cookies, http.MethodDelete, "/api/v1/apps/"+appID, "")
assert.Equal(t, http.StatusOK, rr.Code)
rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID, "")
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
} }
@@ -221,13 +280,17 @@ func TestAPIListDeployments(t *testing.T) {
tc, cookies := setupAPITest(t) tc, cookies := setupAPITest(t)
created, err := tc.appSvc.CreateApp(t.Context(), app.CreateAppInput{ body := `{"name":"deploy-app","repoUrl":"https://github.com/example/repo"}`
Name: "deploy-app", rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
RepoURL: "https://github.com/example/repo", require.Equal(t, http.StatusCreated, rr.Code)
})
require.NoError(t, err)
rr := apiGet(t, tc, cookies, "/api/v1/apps/"+created.ID+"/deployments") var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
appID, ok := created["id"].(string)
require.True(t, ok)
rr = apiRequest(t, tc, cookies, http.MethodGet, "/api/v1/apps/"+appID+"/deployments", "")
assert.Equal(t, http.StatusOK, rr.Code) assert.Equal(t, http.StatusOK, rr.Code)
var deployments []any var deployments []any

View File

@@ -0,0 +1,293 @@
package handlers_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// tokenRouter builds a chi router with token + app API routes.
func tokenRouter(tc *testContext) http.Handler {
r := chi.NewRouter()
r.Route("/api/v1", func(apiR chi.Router) {
apiR.Post("/login", tc.handlers.HandleAPILoginPOST())
apiR.Group(func(apiR chi.Router) {
apiR.Use(tc.middleware.APISessionAuth())
apiR.Get("/whoami", tc.handlers.HandleAPIWhoAmI())
apiR.Post("/tokens", tc.handlers.HandleAPICreateToken())
apiR.Get("/tokens", tc.handlers.HandleAPIListTokens())
apiR.Delete(
"/tokens/{tokenID}",
tc.handlers.HandleAPIDeleteToken(),
)
apiR.Get("/apps", tc.handlers.HandleAPIListApps())
})
})
return r
}
func TestAPICreateToken(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
r := tokenRouter(tc)
body := `{"name":"my-ci-token"}`
req := httptest.NewRequest(
http.MethodPost, "/api/v1/tokens",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusCreated, rr.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
assert.Equal(t, "my-ci-token", resp["name"])
assert.Contains(t, resp["token"], "upaas_")
assert.NotEmpty(t, resp["id"])
}
func TestAPICreateTokenMissingName(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
r := tokenRouter(tc)
body := `{"name":""}`
req := httptest.NewRequest(
http.MethodPost, "/api/v1/tokens",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestAPIListTokens(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
r := tokenRouter(tc)
// Create two tokens.
for _, name := range []string{"token-a", "token-b"} {
body := `{"name":"` + name + `"}`
req := httptest.NewRequest(
http.MethodPost, "/api/v1/tokens",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusCreated, rr.Code)
}
// List tokens.
req := httptest.NewRequest(http.MethodGet, "/api/v1/tokens", nil)
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
var tokens []map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &tokens))
assert.Len(t, tokens, 2)
// Plaintext token must NOT appear in list.
for _, tok := range tokens {
assert.Nil(t, tok["token"])
}
}
func TestAPIDeleteToken(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
r := tokenRouter(tc)
// Create a token.
body := `{"name":"delete-me"}`
req := httptest.NewRequest(
http.MethodPost, "/api/v1/tokens",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusCreated, rr.Code)
var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
tokenID, ok := created["id"].(string)
require.True(t, ok)
// Delete it.
req = httptest.NewRequest(
http.MethodDelete, "/api/v1/tokens/"+tokenID, nil,
)
for _, c := range cookies {
req.AddCookie(c)
}
rr = httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
// List should be empty.
req = httptest.NewRequest(http.MethodGet, "/api/v1/tokens", nil)
for _, c := range cookies {
req.AddCookie(c)
}
rr = httptest.NewRecorder()
r.ServeHTTP(rr, req)
var tokens []map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &tokens))
assert.Empty(t, tokens)
}
func TestAPIBearerTokenAuth(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
r := tokenRouter(tc)
// Create a token via session auth.
body := `{"name":"bearer-test"}`
req := httptest.NewRequest(
http.MethodPost, "/api/v1/tokens",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusCreated, rr.Code)
var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
plaintext, ok := created["token"].(string)
require.True(t, ok)
// Use Bearer token to access an authenticated endpoint.
req = httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
req.Header.Set("Authorization", "Bearer "+plaintext)
rr = httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
}
func TestAPIBearerTokenInvalid(t *testing.T) {
t.Parallel()
tc := setupTestHandlers(t)
r := tokenRouter(tc)
req := httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
req.Header.Set("Authorization", "Bearer upaas_invalidtoken1234567890ab")
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}
func TestAPIBearerTokenRevoked(t *testing.T) {
t.Parallel()
tc, cookies := setupAPITest(t)
r := tokenRouter(tc)
// Create then delete a token.
body := `{"name":"revoke-test"}`
req := httptest.NewRequest(
http.MethodPost, "/api/v1/tokens",
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
req.AddCookie(c)
}
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusCreated, rr.Code)
var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
plaintext, ok := created["token"].(string)
require.True(t, ok)
tokenID, ok := created["id"].(string)
require.True(t, ok)
// Delete (revoke) the token.
req = httptest.NewRequest(
http.MethodDelete, "/api/v1/tokens/"+tokenID, nil,
)
for _, c := range cookies {
req.AddCookie(c)
}
rr = httptest.NewRecorder()
r.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
// Try to use the revoked token.
req = httptest.NewRequest(http.MethodGet, "/api/v1/apps", nil)
req.Header.Set("Authorization", "Bearer "+plaintext)
rr = httptest.NewRecorder()
r.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}

View File

@@ -0,0 +1,220 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/models"
)
// apiTokenResponse is the JSON representation of an API token.
type apiTokenResponse struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"createdAt"`
ExpiresAt *string `json:"expiresAt,omitempty"`
LastUsedAt *string `json:"lastUsedAt,omitempty"`
}
// apiTokenCreateResponse includes the plaintext token (shown once).
type apiTokenCreateResponse struct {
apiTokenResponse
Token string `json:"token"`
}
func tokenToAPI(t *models.APIToken) apiTokenResponse {
resp := apiTokenResponse{
ID: t.ID,
Name: t.Name,
CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
if t.ExpiresAt.Valid {
s := t.ExpiresAt.Time.Format("2006-01-02T15:04:05Z")
resp.ExpiresAt = &s
}
if t.LastUsedAt.Valid {
s := t.LastUsedAt.Time.Format("2006-01-02T15:04:05Z")
resp.LastUsedAt = &s
}
return resp
}
// createTokenRequest is the JSON body for token creation.
type createTokenRequest struct {
Name string `json:"name"`
}
// createAndSaveToken generates a token, saves it, and returns
// the plaintext and model.
func (h *Handlers) createAndSaveToken(
ctx context.Context,
userID int64,
name string,
) (string, *models.APIToken, error) {
plaintext, err := models.GenerateToken()
if err != nil {
return "", nil, fmt.Errorf("generating: %w", err)
}
token := models.NewAPIToken(h.db)
token.UserID = userID
token.Name = name
token.TokenHash = database.HashAPIToken(plaintext)
saveErr := token.Save(ctx)
if saveErr != nil {
return "", nil, fmt.Errorf("saving: %w", saveErr)
}
return plaintext, token, nil
}
// HandleAPICreateToken returns a handler that creates an API token.
func (h *Handlers) HandleAPICreateToken() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
user, err := h.auth.GetCurrentUser(
request.Context(), request,
)
if err != nil || user == nil {
h.respondJSON(writer, request,
map[string]string{"error": "unauthorized"},
http.StatusUnauthorized)
return
}
var req createTokenRequest
decodeErr := json.NewDecoder(request.Body).Decode(&req)
if decodeErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "invalid JSON body"},
http.StatusBadRequest)
return
}
if req.Name == "" {
h.respondJSON(writer, request,
map[string]string{"error": "name is required"},
http.StatusBadRequest)
return
}
plaintext, token, createErr := h.createAndSaveToken(
request.Context(), user.ID, req.Name,
)
if createErr != nil {
h.log.Error("api: token creation failed",
"error", createErr)
h.respondJSON(writer, request,
map[string]string{"error": "internal error"},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request, apiTokenCreateResponse{
apiTokenResponse: tokenToAPI(token),
Token: plaintext,
}, http.StatusCreated)
}
}
// HandleAPIListTokens returns a handler that lists API tokens.
func (h *Handlers) HandleAPIListTokens() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
user, err := h.auth.GetCurrentUser(
request.Context(), request,
)
if err != nil || user == nil {
h.respondJSON(writer, request,
map[string]string{"error": "unauthorized"},
http.StatusUnauthorized)
return
}
tokens, listErr := models.ListAPITokensByUser(
request.Context(), h.db, user.ID,
)
if listErr != nil {
h.log.Error("api: failed to list tokens",
"error", listErr)
h.respondJSON(writer, request,
map[string]string{"error": "internal error"},
http.StatusInternalServerError)
return
}
result := make([]apiTokenResponse, 0, len(tokens))
for _, t := range tokens {
result = append(result, tokenToAPI(t))
}
h.respondJSON(writer, request, result, http.StatusOK)
}
}
// HandleAPIDeleteToken returns a handler that revokes an API token.
func (h *Handlers) HandleAPIDeleteToken() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
user, err := h.auth.GetCurrentUser(
request.Context(), request,
)
if err != nil || user == nil {
h.respondJSON(writer, request,
map[string]string{"error": "unauthorized"},
http.StatusUnauthorized)
return
}
tokenID := chi.URLParam(request, "tokenID")
token, findErr := models.FindAPIToken(
request.Context(), h.db, tokenID,
)
if findErr != nil {
h.respondJSON(writer, request,
map[string]string{"error": "internal error"},
http.StatusInternalServerError)
return
}
if token == nil || token.UserID != user.ID {
h.respondJSON(writer, request,
map[string]string{"error": "token not found"},
http.StatusNotFound)
return
}
deleteErr := token.Delete(request.Context())
if deleteErr != nil {
h.log.Error("api: failed to delete token",
"error", deleteErr)
h.respondJSON(writer, request,
map[string]string{"error": "internal error"},
http.StatusInternalServerError)
return
}
h.respondJSON(writer, request,
map[string]string{"status": "deleted"},
http.StatusOK)
}
}

View File

@@ -77,14 +77,6 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid
return return
} }
repoURLErr := validateRepoURL(repoURL)
if repoURLErr != nil {
data["Error"] = "Invalid repository URL: " + repoURLErr.Error()
h.renderTemplate(writer, tmpl, "app_new.html", data)
return
}
if branch == "" { if branch == "" {
branch = "main" branch = "main"
} }
@@ -233,17 +225,6 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
return return
} }
repoURLErr := validateRepoURL(request.FormValue("repo_url"))
if repoURLErr != nil {
data := h.addGlobals(map[string]any{
"App": application,
"Error": "Invalid repository URL: " + repoURLErr.Error(),
}, request)
_ = tmpl.ExecuteTemplate(writer, "app_edit.html", data)
return
}
application.Name = newName application.Name = newName
application.RepoURL = request.FormValue("repo_url") application.RepoURL = request.FormValue("repo_url")
application.Branch = request.FormValue("branch") application.Branch = request.FormValue("branch")
@@ -518,7 +499,8 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc {
return return
} }
_, _ = writer.Write([]byte(SanitizeLogs(logs))) // #nosec G705 -- logs sanitized, Content-Type is text/plain //nolint:gosec // logs sanitized: ANSI escapes and control chars stripped
_, _ = writer.Write([]byte(SanitizeLogs(logs)))
} }
} }
@@ -553,11 +535,11 @@ func (h *Handlers) HandleDeploymentLogsAPI() http.HandlerFunc {
logs := "" logs := ""
if deployment.Logs.Valid { if deployment.Logs.Valid {
logs = SanitizeLogs(deployment.Logs.String) logs = deployment.Logs.String
} }
response := map[string]any{ response := map[string]any{
"logs": logs, "logs": SanitizeLogs(logs),
"status": deployment.Status, "status": deployment.Status,
} }
@@ -600,8 +582,8 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc {
return return
} }
// Check if file exists — logPath is constructed internally, not from user input // Check if file exists
_, err := os.Stat(logPath) // #nosec G703 -- path from internal GetLogFilePath, not user input _, err := os.Stat(logPath) //nolint:gosec // logPath is constructed by deploy service, not from user input
if os.IsNotExist(err) { if os.IsNotExist(err) {
http.NotFound(writer, request) http.NotFound(writer, request)
@@ -916,7 +898,7 @@ func (h *Handlers) HandleEnvVarAdd() http.HandlerFunc {
func (h *Handlers) HandleEnvVarDelete() http.HandlerFunc { func (h *Handlers) HandleEnvVarDelete() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id") appID := chi.URLParam(request, "id")
envVarIDStr := chi.URLParam(request, "varID") envVarIDStr := chi.URLParam(request, "envID")
envVarID, parseErr := strconv.ParseInt(envVarIDStr, 10, 64) envVarID, parseErr := strconv.ParseInt(envVarIDStr, 10, 64)
if parseErr != nil { if parseErr != nil {
@@ -1022,14 +1004,6 @@ func (h *Handlers) HandleVolumeAdd() http.HandlerFunc {
return return
} }
pathErr := validateVolumePaths(hostPath, containerPath)
if pathErr != nil {
h.log.Error("invalid volume path", "error", pathErr)
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther)
return
}
volume := models.NewVolume(h.db) volume := models.NewVolume(h.db)
volume.AppID = application.ID volume.AppID = application.ID
volume.HostPath = hostPath volume.HostPath = hostPath

View File

@@ -1,6 +0,0 @@
package handlers
// ValidateRepoURLForTest exports validateRepoURL for testing.
func ValidateRepoURLForTest(repoURL string) error {
return validateRepoURL(repoURL)
}

View File

@@ -173,6 +173,7 @@ func setupTestHandlers(t *testing.T) *testContext {
Globals: globalInstance, Globals: globalInstance,
Config: cfg, Config: cfg,
Auth: authSvc, Auth: authSvc,
Database: dbInstance,
}) })
require.NoError(t, mwErr) require.NoError(t, mwErr)
@@ -564,7 +565,7 @@ func TestDeleteEnvVarOwnershipVerification(t *testing.T) { //nolint:dupl // inte
return "/apps/" + appID + "/env/" + strconv.FormatInt(resourceID, 10) + "/delete" return "/apps/" + appID + "/env/" + strconv.FormatInt(resourceID, 10) + "/delete"
}, },
chiParams: func(appID string, resourceID int64) map[string]string { chiParams: func(appID string, resourceID int64) map[string]string {
return map[string]string{"id": appID, "varID": strconv.FormatInt(resourceID, 10)} return map[string]string{"id": appID, "envID": strconv.FormatInt(resourceID, 10)}
}, },
handler: func(h *handlers.Handlers) http.HandlerFunc { return h.HandleEnvVarDelete() }, handler: func(h *handlers.Handlers) http.HandlerFunc { return h.HandleEnvVarDelete() },
verifyFn: func(t *testing.T, tc *testContext, resourceID int64) { verifyFn: func(t *testing.T, tc *testContext, resourceID int64) {
@@ -695,153 +696,6 @@ func TestDeletePortOwnershipVerification(t *testing.T) {
assert.NotNil(t, found, "port should still exist after IDOR attempt") assert.NotNil(t, found, "port should still exist after IDOR attempt")
} }
// TestHandleEnvVarDeleteUsesCorrectRouteParam verifies that HandleEnvVarDelete
// reads the "varID" chi URL parameter (matching the route definition {varID}),
// not a mismatched name like "envID".
func TestHandleEnvVarDeleteUsesCorrectRouteParam(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "envdelete-param-app")
envVar := models.NewEnvVar(testCtx.database)
envVar.AppID = createdApp.ID
envVar.Key = "DELETE_ME"
envVar.Value = "gone"
require.NoError(t, envVar.Save(context.Background()))
// Use chi router with the real route pattern to test param name
r := chi.NewRouter()
r.Post("/apps/{id}/env-vars/{varID}/delete", testCtx.handlers.HandleEnvVarDelete())
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/env-vars/"+strconv.FormatInt(envVar.ID, 10)+"/delete",
nil,
)
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusSeeOther, recorder.Code)
// Verify the env var was actually deleted
found, findErr := models.FindEnvVar(context.Background(), testCtx.database, envVar.ID)
require.NoError(t, findErr)
assert.Nil(t, found, "env var should be deleted when using correct route param")
}
// TestHandleVolumeAddValidatesPaths verifies that HandleVolumeAdd validates
// host and container paths (same as HandleVolumeEdit).
func TestHandleVolumeAddValidatesPaths(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "volume-validate-app")
tests := []struct {
name string
hostPath string
containerPath string
shouldCreate bool
}{
{"relative host path rejected", "relative/path", "/container", false},
{"relative container path rejected", "/host", "relative/path", false},
{"unclean host path rejected", "/host/../etc", "/container", false},
{"valid paths accepted", "/host/data", "/container/data", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
form := url.Values{}
form.Set("host_path", tt.hostPath)
form.Set("container_path", tt.containerPath)
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/volumes",
strings.NewReader(form.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request = addChiURLParams(request, map[string]string{"id": createdApp.ID})
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleVolumeAdd()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusSeeOther, recorder.Code)
// Check if volume was created by listing volumes
volumes, _ := createdApp.GetVolumes(context.Background())
found := false
for _, v := range volumes {
if v.HostPath == tt.hostPath && v.ContainerPath == tt.containerPath {
found = true
// Clean up for isolation
_ = v.Delete(context.Background())
}
}
if tt.shouldCreate {
assert.True(t, found, "volume should be created for valid paths")
} else {
assert.False(t, found, "volume should NOT be created for invalid paths")
}
})
}
}
// TestSetupRequiredExemptsHealthAndStaticAndAPI verifies that the SetupRequired
// middleware allows /health, /s/*, and /api/* paths through even when setup is required.
func TestSetupRequiredExemptsHealthAndStaticAndAPI(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
// No user created, so setup IS required
mw := testCtx.middleware.SetupRequired()
okHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
})
wrapped := mw(okHandler)
exemptPaths := []string{"/health", "/s/style.css", "/s/js/app.js", "/api/v1/apps", "/api/v1/login"}
for _, path := range exemptPaths {
t.Run(path, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, path, nil)
rr := httptest.NewRecorder()
wrapped.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code,
"path %s should be exempt from setup redirect", path)
})
}
// Non-exempt path should redirect to /setup
t.Run("non-exempt redirects", func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rr := httptest.NewRecorder()
wrapped.ServeHTTP(rr, req)
assert.Equal(t, http.StatusSeeOther, rr.Code)
assert.Equal(t, "/setup", rr.Header().Get("Location"))
})
}
func TestHandleCancelDeployRedirects(t *testing.T) { func TestHandleCancelDeployRedirects(t *testing.T) {
t.Parallel() t.Parallel()

View File

@@ -1,77 +0,0 @@
package handlers
import (
"errors"
"net/url"
"regexp"
"strings"
)
// Repo URL validation errors.
var (
errRepoURLEmpty = errors.New("repository URL must not be empty")
errRepoURLScheme = errors.New("file:// URLs are not allowed for security reasons")
errRepoURLInvalid = errors.New("repository URL must use https://, http://, ssh://, git://, or git@host:path format")
errRepoURLNoHost = errors.New("repository URL must include a host")
errRepoURLNoPath = errors.New("repository URL must include a path")
)
// scpLikeRepoRe matches SCP-like git URLs: git@host:path (e.g. git@github.com:user/repo.git).
// Only the "git" user is allowed, as that is the standard for SSH deploy keys.
var scpLikeRepoRe = regexp.MustCompile(`^git@[a-zA-Z0-9._-]+:.+$`)
// allowedRepoSchemes lists the URL schemes accepted for repository URLs.
//
//nolint:gochecknoglobals // package-level constant map parsed once
var allowedRepoSchemes = map[string]bool{
"https": true,
"http": true,
"ssh": true,
"git": true,
}
// validateRepoURL checks that the given repository URL is valid and uses an allowed scheme.
func validateRepoURL(repoURL string) error {
if strings.TrimSpace(repoURL) == "" {
return errRepoURLEmpty
}
// Reject path traversal in any URL format
if strings.Contains(repoURL, "..") {
return errRepoURLInvalid
}
// Check for SCP-like git URLs first (git@host:path)
if scpLikeRepoRe.MatchString(repoURL) {
return nil
}
// Reject file:// explicitly
if strings.HasPrefix(strings.ToLower(repoURL), "file://") {
return errRepoURLScheme
}
return validateParsedRepoURL(repoURL)
}
// validateParsedRepoURL validates a standard URL-format repository URL.
func validateParsedRepoURL(repoURL string) error {
parsed, err := url.Parse(repoURL)
if err != nil {
return errRepoURLInvalid
}
if !allowedRepoSchemes[strings.ToLower(parsed.Scheme)] {
return errRepoURLInvalid
}
if parsed.Host == "" {
return errRepoURLNoHost
}
if parsed.Path == "" || parsed.Path == "/" {
return errRepoURLNoPath
}
return nil
}

View File

@@ -1,60 +0,0 @@
package handlers_test
import (
"testing"
"git.eeqj.de/sneak/upaas/internal/handlers"
)
func TestValidateRepoURL(t *testing.T) {
t.Parallel()
tests := []struct {
name string
url string
wantErr bool
}{
// Valid URLs
{name: "https URL", url: "https://github.com/user/repo.git", wantErr: false},
{name: "http URL", url: "http://github.com/user/repo.git", wantErr: false},
{name: "ssh URL", url: "ssh://git@github.com/user/repo.git", wantErr: false},
{name: "git URL", url: "git://github.com/user/repo.git", wantErr: false},
{name: "SCP-like URL", url: "git@github.com:user/repo.git", wantErr: false},
{name: "SCP-like with dots", url: "git@git.example.com:org/repo.git", wantErr: false},
{name: "https without .git", url: "https://github.com/user/repo", wantErr: false},
{name: "https with port", url: "https://git.example.com:8443/user/repo.git", wantErr: false},
// Invalid URLs
{name: "empty string", url: "", wantErr: true},
{name: "whitespace only", url: " ", wantErr: true},
{name: "file URL", url: "file:///etc/passwd", wantErr: true},
{name: "file URL uppercase", url: "FILE:///etc/passwd", wantErr: true},
{name: "bare path", url: "/some/local/path", wantErr: true},
{name: "relative path", url: "../repo", wantErr: true},
{name: "just a word", url: "notaurl", wantErr: true},
{name: "ftp URL", url: "ftp://example.com/repo.git", wantErr: true},
{name: "no host https", url: "https:///path", wantErr: true},
{name: "no path https", url: "https://github.com", wantErr: true},
{name: "no path https trailing slash", url: "https://github.com/", wantErr: true},
{name: "SCP-like non-git user", url: "root@github.com:user/repo.git", wantErr: true},
{name: "SCP-like arbitrary user", url: "admin@github.com:user/repo.git", wantErr: true},
{name: "path traversal SCP", url: "git@github.com:../../etc/passwd", wantErr: true},
{name: "path traversal https", url: "https://github.com/user/../../../etc/passwd", wantErr: true},
{name: "path traversal in middle", url: "https://github.com/user/repo/../secret", wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := handlers.ValidateRepoURLForTest(tc.url)
if tc.wantErr && err == nil {
t.Errorf("ValidateRepoURLForTest(%q) = nil, want error", tc.url)
}
if !tc.wantErr && err != nil {
t.Errorf("ValidateRepoURLForTest(%q) = %v, want nil", tc.url, err)
}
})
}
}

View File

@@ -0,0 +1,160 @@
package middleware_test
import (
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/middleware"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/auth"
)
// setupMiddleware creates a Middleware with a real SQLite database for
// integration testing.
func setupMiddleware(t *testing.T) (*middleware.Middleware, *auth.Service, *database.Database) {
t.Helper()
tmpDir := t.TempDir()
globals.SetAppname("upaas-test")
globals.SetVersion("test")
globalsInst, err := globals.New(fx.Lifecycle(nil))
require.NoError(t, err)
loggerInst, err := logger.New(
fx.Lifecycle(nil),
logger.Params{Globals: globalsInst},
)
require.NoError(t, err)
cfg := &config.Config{
Port: 8080,
DataDir: tmpDir,
SessionSecret: "test-secret-key-at-least-32-chars!!",
}
_ = filepath.Join(tmpDir, "upaas.db")
dbInst, err := database.New(fx.Lifecycle(nil), database.Params{
Logger: loggerInst,
Config: cfg,
})
require.NoError(t, err)
authSvc, err := auth.New(fx.Lifecycle(nil), auth.ServiceParams{
Logger: loggerInst,
Config: cfg,
Database: dbInst,
})
require.NoError(t, err)
mw, err := middleware.New(fx.Lifecycle(nil), middleware.Params{
Logger: loggerInst,
Globals: globalsInst,
Config: cfg,
Auth: authSvc,
Database: dbInst,
})
require.NoError(t, err)
return mw, authSvc, dbInst
}
func TestAPISessionAuth_BearerTokenSetsUserContext(t *testing.T) {
t.Parallel()
mw, authSvc, dbInst := setupMiddleware(t)
ctx := t.Context()
// Create a user.
user, err := authSvc.CreateUser(ctx, "testuser", "password123")
require.NoError(t, err)
require.NotNil(t, user)
// Create an API token for the user.
rawToken, err := models.GenerateToken()
require.NoError(t, err)
tokenHash := database.HashAPIToken(rawToken)
apiToken := models.NewAPIToken(dbInst)
apiToken.UserID = user.ID
apiToken.Name = "test-token"
apiToken.TokenHash = tokenHash
err = apiToken.Save(ctx)
require.NoError(t, err)
// Build a handler behind APISessionAuth that checks user context.
var gotUser *models.User
var getUserErr error
handler := mw.APISessionAuth()(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
gotUser, getUserErr = authSvc.GetCurrentUser(r.Context(), r)
w.WriteHeader(http.StatusOK)
},
))
// Make request with bearer token.
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Header.Set("Authorization", "Bearer "+rawToken)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
require.NoError(t, getUserErr)
require.NotNil(t, gotUser, "GetCurrentUser should return the user for bearer auth")
assert.Equal(t, user.ID, gotUser.ID)
assert.Equal(t, "testuser", gotUser.Username)
}
func TestAPISessionAuth_NoBearerTokenReturns401(t *testing.T) {
t.Parallel()
mw, _, _ := setupMiddleware(t)
handler := mw.APISessionAuth()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
))
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
}
func TestAPISessionAuth_InvalidBearerTokenReturns401(t *testing.T) {
t.Parallel()
mw, _, _ := setupMiddleware(t)
handler := mw.APISessionAuth()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
))
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Header.Set("Authorization", "Bearer invalid-token")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
}

View File

@@ -19,8 +19,10 @@ import (
"golang.org/x/time/rate" "golang.org/x/time/rate"
"git.eeqj.de/sneak/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/auth" "git.eeqj.de/sneak/upaas/internal/service/auth"
) )
@@ -35,6 +37,7 @@ type Params struct {
Globals *globals.Globals Globals *globals.Globals
Config *config.Config Config *config.Config
Auth *auth.Service Auth *auth.Service
Database *database.Database
} }
// Middleware provides HTTP middleware. // Middleware provides HTTP middleware.
@@ -370,18 +373,36 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
} }
} }
// APISessionAuth returns middleware that requires session authentication for API routes. // bearerPrefix is the expected prefix for Authorization headers.
// Unlike SessionAuth, it returns JSON 401 responses instead of redirecting to /login. const bearerPrefix = "Bearer "
// APISessionAuth returns middleware that requires authentication
// for API routes. It checks Bearer token first, then falls back
// to session cookie. Returns JSON 401 on failure.
func (m *Middleware) APISessionAuth() func(http.Handler) http.Handler { func (m *Middleware) APISessionAuth() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func( return http.HandlerFunc(func(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
) { ) {
user, err := m.params.Auth.GetCurrentUser(request.Context(), request) // Try Bearer token first.
if authedReq, ok := m.tryBearerAuth(request); ok {
next.ServeHTTP(writer, authedReq)
return
}
// Fall back to session cookie.
user, err := m.params.Auth.GetCurrentUser(
request.Context(), request,
)
if err != nil || user == nil { if err != nil || user == nil {
writer.Header().Set("Content-Type", "application/json") writer.Header().Set("Content-Type", "application/json")
http.Error(writer, `{"error":"unauthorized"}`, http.StatusUnauthorized) http.Error(
writer,
`{"error":"unauthorized"}`,
http.StatusUnauthorized,
)
return return
} }
@@ -411,14 +432,8 @@ func (m *Middleware) SetupRequired() func(http.Handler) http.Handler {
} }
if setupRequired { if setupRequired {
path := request.URL.Path // Allow access to setup page
if request.URL.Path == "/setup" {
// Allow access to setup page, health endpoint, static
// assets, and API routes even before setup is complete.
if path == "/setup" ||
path == "/health" ||
strings.HasPrefix(path, "/s/") ||
strings.HasPrefix(path, "/api/") {
next.ServeHTTP(writer, request) next.ServeHTTP(writer, request)
return return
@@ -440,3 +455,49 @@ func (m *Middleware) SetupRequired() func(http.Handler) http.Handler {
}) })
} }
} }
// tryBearerAuth checks for a valid Bearer token in the
// Authorization header. On success it returns a new request
// with the authenticated user set on the context.
func (m *Middleware) tryBearerAuth(
request *http.Request,
) (*http.Request, bool) {
authHeader := request.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, bearerPrefix) {
return request, false
}
rawToken := strings.TrimPrefix(authHeader, bearerPrefix)
if rawToken == "" {
return request, false
}
tokenHash := database.HashAPIToken(rawToken)
apiToken, err := models.FindAPITokenByHash(
request.Context(), m.params.Database, tokenHash,
)
if err != nil || apiToken == nil {
return request, false
}
if apiToken.IsExpired() {
return request, false
}
// Look up the user associated with the token.
user, err := models.FindUser(
request.Context(), m.params.Database, apiToken.UserID,
)
if err != nil || user == nil {
return request, false
}
// Update last_used_at (best effort).
_ = apiToken.TouchLastUsed(request.Context())
// Set the authenticated user on the request context.
ctx := auth.ContextWithUser(request.Context(), user)
return request.WithContext(ctx), true
}

View File

@@ -0,0 +1,206 @@
package models
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/oklog/ulid/v2"
"git.eeqj.de/sneak/upaas/internal/database"
)
// tokenRandomBytes is the number of random bytes for token generation.
const tokenRandomBytes = 32
// tokenPrefix is prepended to generated API tokens.
const tokenPrefix = "upaas_"
// APIToken represents an API authentication token.
type APIToken struct {
db *database.Database
ID string
UserID int64
Name string
TokenHash string
CreatedAt time.Time
ExpiresAt sql.NullTime
LastUsedAt sql.NullTime
}
// NewAPIToken creates a new APIToken with a database reference.
func NewAPIToken(db *database.Database) *APIToken {
return &APIToken{db: db}
}
// GenerateToken generates a random API token string.
func GenerateToken() (string, error) {
b := make([]byte, tokenRandomBytes)
_, err := rand.Read(b)
if err != nil {
return "", fmt.Errorf("generating token: %w", err)
}
return tokenPrefix + hex.EncodeToString(b), nil
}
// Save inserts the API token into the database.
func (t *APIToken) Save(ctx context.Context) error {
if t.ID == "" {
t.ID = ulid.Make().String()
}
query := `INSERT INTO api_tokens
(id, user_id, name, token_hash, expires_at)
VALUES (?, ?, ?, ?, ?)`
_, err := t.db.Exec(
ctx, query,
t.ID, t.UserID, t.Name, t.TokenHash, t.ExpiresAt,
)
if err != nil {
return fmt.Errorf("inserting api token: %w", err)
}
return t.Reload(ctx)
}
// Reload refreshes the token from the database.
func (t *APIToken) Reload(ctx context.Context) error {
row := t.db.QueryRow(ctx,
`SELECT id, user_id, name, token_hash,
created_at, expires_at, last_used_at
FROM api_tokens WHERE id = ?`, t.ID)
return t.scan(row)
}
// Delete removes the token from the database.
func (t *APIToken) Delete(ctx context.Context) error {
_, err := t.db.Exec(ctx,
"DELETE FROM api_tokens WHERE id = ?", t.ID)
return err
}
// TouchLastUsed updates the last_used_at timestamp.
func (t *APIToken) TouchLastUsed(ctx context.Context) error {
_, err := t.db.Exec(ctx,
"UPDATE api_tokens SET last_used_at = ? WHERE id = ?",
time.Now().UTC(), t.ID)
return err
}
// IsExpired reports whether the token has expired.
func (t *APIToken) IsExpired() bool {
return t.ExpiresAt.Valid && t.ExpiresAt.Time.Before(time.Now())
}
func (t *APIToken) scan(row *sql.Row) error {
return row.Scan(
&t.ID, &t.UserID, &t.Name, &t.TokenHash,
&t.CreatedAt, &t.ExpiresAt, &t.LastUsedAt,
)
}
// FindAPITokenByHash finds a token by its hash.
//
//nolint:nilnil // nil,nil is idiomatic for "not found"
func FindAPITokenByHash(
ctx context.Context,
db *database.Database,
hash string,
) (*APIToken, error) {
token := NewAPIToken(db)
row := db.QueryRow(ctx,
`SELECT id, user_id, name, token_hash,
created_at, expires_at, last_used_at
FROM api_tokens WHERE token_hash = ?`, hash)
err := token.scan(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("finding api token by hash: %w", err)
}
return token, nil
}
// FindAPIToken finds a token by ID.
//
//nolint:nilnil // nil,nil is idiomatic for "not found"
func FindAPIToken(
ctx context.Context,
db *database.Database,
id string,
) (*APIToken, error) {
token := NewAPIToken(db)
row := db.QueryRow(ctx,
`SELECT id, user_id, name, token_hash,
created_at, expires_at, last_used_at
FROM api_tokens WHERE id = ?`, id)
err := token.scan(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("finding api token: %w", err)
}
return token, nil
}
// ListAPITokensByUser returns all tokens for a user.
func ListAPITokensByUser(
ctx context.Context,
db *database.Database,
userID int64,
) ([]*APIToken, error) {
rows, err := db.Query(ctx,
`SELECT id, user_id, name, token_hash,
created_at, expires_at, last_used_at
FROM api_tokens WHERE user_id = ?
ORDER BY created_at DESC`, userID)
if err != nil {
return nil, fmt.Errorf("listing api tokens: %w", err)
}
defer func() { _ = rows.Close() }()
var tokens []*APIToken
for rows.Next() {
t := NewAPIToken(db)
scanErr := rows.Scan(
&t.ID, &t.UserID, &t.Name, &t.TokenHash,
&t.CreatedAt, &t.ExpiresAt, &t.LastUsedAt,
)
if scanErr != nil {
return nil, fmt.Errorf("scanning api token: %w", scanErr)
}
tokens = append(tokens, t)
}
rowsErr := rows.Err()
if rowsErr != nil {
return nil, fmt.Errorf("iterating api tokens: %w", rowsErr)
}
return tokens, nil
}

View File

@@ -114,8 +114,16 @@ func (s *Server) SetupRoutes() {
r.Get("/whoami", s.handlers.HandleAPIWhoAmI()) r.Get("/whoami", s.handlers.HandleAPIWhoAmI())
r.Get("/apps", s.handlers.HandleAPIListApps()) r.Get("/apps", s.handlers.HandleAPIListApps())
r.Post("/apps", s.handlers.HandleAPICreateApp())
r.Get("/apps/{id}", s.handlers.HandleAPIGetApp()) r.Get("/apps/{id}", s.handlers.HandleAPIGetApp())
r.Delete("/apps/{id}", s.handlers.HandleAPIDeleteApp())
r.Post("/apps/{id}/deploy", s.handlers.HandleAPITriggerDeploy())
r.Get("/apps/{id}/deployments", s.handlers.HandleAPIListDeployments()) r.Get("/apps/{id}/deployments", s.handlers.HandleAPIListDeployments())
// API token management
r.Post("/tokens", s.handlers.HandleAPICreateToken())
r.Get("/tokens", s.handlers.HandleAPIListTokens())
r.Delete("/tokens/{tokenID}", s.handlers.HandleAPIDeleteToken())
}) })
}) })

View File

@@ -26,6 +26,21 @@ const (
sessionUserID = "user_id" sessionUserID = "user_id"
) )
// contextKeyUser is the context key for storing the authenticated user.
type contextKeyUser struct{}
// ContextWithUser returns a new context with the given user attached.
func ContextWithUser(ctx context.Context, user *models.User) context.Context {
return context.WithValue(ctx, contextKeyUser{}, user)
}
// UserFromContext retrieves the user from the context, if set.
func UserFromContext(ctx context.Context) *models.User {
user, _ := ctx.Value(contextKeyUser{}).(*models.User)
return user
}
// Argon2 parameters. // Argon2 parameters.
const ( const (
argonTime = 1 argonTime = 1
@@ -239,6 +254,11 @@ func (svc *Service) GetCurrentUser(
ctx context.Context, ctx context.Context,
request *http.Request, request *http.Request,
) (*models.User, error) { ) (*models.User, error) {
// Check context first (set by bearer token auth).
if user := UserFromContext(ctx); user != nil {
return user, nil
}
session, sessionErr := svc.store.Get(request, sessionName) session, sessionErr := svc.store.Get(request, sessionName)
if sessionErr != nil { if sessionErr != nil {
// Session error means no user - this is not an error condition // Session error means no user - this is not an error condition

View File

@@ -10,7 +10,6 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"time" "time"
"go.uber.org/fx" "go.uber.org/fx"
@@ -248,15 +247,10 @@ func (svc *Service) sendNtfy(
) error { ) error {
svc.log.Debug("sending ntfy notification", "topic", topic, "title", title) svc.log.Debug("sending ntfy notification", "topic", topic, "title", title)
parsedURL, err := url.ParseRequestURI(topic)
if err != nil {
return fmt.Errorf("invalid ntfy topic URL: %w", err)
}
request, err := http.NewRequestWithContext( request, err := http.NewRequestWithContext(
ctx, ctx,
http.MethodPost, http.MethodPost,
parsedURL.String(), topic,
bytes.NewBufferString(message), bytes.NewBufferString(message),
) )
if err != nil { if err != nil {
@@ -266,7 +260,7 @@ func (svc *Service) sendNtfy(
request.Header.Set("Title", title) request.Header.Set("Title", title)
request.Header.Set("Priority", svc.ntfyPriority(priority)) request.Header.Set("Priority", svc.ntfyPriority(priority))
resp, err := svc.client.Do(request) // #nosec G704 -- URL from validated config, not user input resp, err := svc.client.Do(request) //nolint:gosec // URL constructed from trusted config, not user input
if err != nil { if err != nil {
return fmt.Errorf("failed to send ntfy request: %w", err) return fmt.Errorf("failed to send ntfy request: %w", err)
} }
@@ -346,15 +340,10 @@ func (svc *Service) sendSlack(
return fmt.Errorf("failed to marshal slack payload: %w", err) return fmt.Errorf("failed to marshal slack payload: %w", err)
} }
parsedWebhookURL, err := url.ParseRequestURI(webhookURL)
if err != nil {
return fmt.Errorf("invalid slack webhook URL: %w", err)
}
request, err := http.NewRequestWithContext( request, err := http.NewRequestWithContext(
ctx, ctx,
http.MethodPost, http.MethodPost,
parsedWebhookURL.String(), webhookURL,
bytes.NewBuffer(body), bytes.NewBuffer(body),
) )
if err != nil { if err != nil {
@@ -363,7 +352,7 @@ func (svc *Service) sendSlack(
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
resp, err := svc.client.Do(request) // #nosec G704 -- URL from validated config, not user input resp, err := svc.client.Do(request) //nolint:gosec // URL from trusted webhook config
if err != nil { if err != nil {
return fmt.Errorf("failed to send slack request: %w", err) return fmt.Errorf("failed to send slack request: %w", err)
} }

View File

@@ -12,7 +12,7 @@ import (
// KeyPair contains an SSH key pair. // KeyPair contains an SSH key pair.
type KeyPair struct { type KeyPair struct {
PrivateKey string `json:"-"` PrivateKey string //nolint:gosec // field name describes SSH key material, not a hardcoded secret
PublicKey string PublicKey string
} }

View File

@@ -369,7 +369,7 @@ document.addEventListener("alpine:init", () => {
init() { init() {
// Read initial logs from script tag (avoids escaping issues) // Read initial logs from script tag (avoids escaping issues)
const initialLogsEl = this.$el.querySelector(".initial-logs"); const initialLogsEl = this.$el.querySelector(".initial-logs");
this.logs = initialLogsEl?.dataset.logs || "Loading..."; this.logs = initialLogsEl?.textContent || "Loading...";
// Set up scroll tracking // Set up scroll tracking
this.$nextTick(() => { this.$nextTick(() => {

View File

@@ -98,7 +98,7 @@
title="Scroll to bottom" title="Scroll to bottom"
>↓ Follow</button> >↓ Follow</button>
</div> </div>
{{if .Logs.Valid}}<div hidden class="initial-logs" data-logs="{{.Logs.String}}"></div>{{end}} {{if .Logs.Valid}}<script type="text/plain" class="initial-logs">{{.Logs.String}}</script>{{end}}
</div> </div>
{{end}} {{end}}
</div> </div>