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
This commit is contained in:
293
internal/handlers/api_token_test.go
Normal file
293
internal/handlers/api_token_test.go
Normal 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)
|
||||
}
|
||||
220
internal/handlers/api_tokens.go
Normal file
220
internal/handlers/api_tokens.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -169,10 +169,11 @@ func setupTestHandlers(t *testing.T) *testContext {
|
||||
require.NoError(t, handlerErr)
|
||||
|
||||
mw, mwErr := middleware.New(fx.Lifecycle(nil), middleware.Params{
|
||||
Logger: logInstance,
|
||||
Globals: globalInstance,
|
||||
Config: cfg,
|
||||
Auth: authSvc,
|
||||
Logger: logInstance,
|
||||
Globals: globalInstance,
|
||||
Config: cfg,
|
||||
Auth: authSvc,
|
||||
Database: dbInstance,
|
||||
})
|
||||
require.NoError(t, mwErr)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user