- 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
221 lines
5.2 KiB
Go
221 lines
5.2 KiB
Go
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)
|
|
}
|
|
}
|